refactor: 使用flutter对原先的web项目重构,正在进行中
This commit is contained in:
1 parent
273224f5cf
commit
18017f819a
205 files changed
+13563
No files matched your search
@@ -0,0 +1,324 @@
|
||||
:root{
|
||||
--bg:#0a0a10; --bg2:#13131c; --line:#2c2c3c;
|
||||
--fg:#e8e8f0; --dim:#9a9ab0;
|
||||
--red:#e03050; --red2:#ff5c7a;
|
||||
--gold:#ffc63a; --green:#3ddc84; --blue:#4aa8ff; --purple:#a86bff;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;overscroll-behavior:none}
|
||||
body{
|
||||
background:
|
||||
radial-gradient(circle at 50% 20%, #1a1a28 0%, #08080d 70%);
|
||||
color:var(--fg);
|
||||
font-family:"Courier New",Consolas,Monaco,monospace;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
overflow:hidden;
|
||||
}
|
||||
#app{width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:8px}
|
||||
#stage{
|
||||
position:relative;
|
||||
width:min(100%, calc((100vh - 16px) * 800 / 608));
|
||||
aspect-ratio:800/608;
|
||||
background:#000;
|
||||
border:3px solid var(--line);
|
||||
box-shadow:0 0 0 3px #000, 0 12px 60px rgba(0,0,0,.8);
|
||||
overflow:hidden;
|
||||
}
|
||||
canvas#game{
|
||||
width:100%;height:100%;display:block;
|
||||
image-rendering:pixelated;image-rendering:crisp-edges;
|
||||
background:#0d0d16;
|
||||
}
|
||||
|
||||
/* ---------- HUD ---------- */
|
||||
#hud{position:absolute;inset:0;pointer-events:none;font-size:clamp(9px,1.35vh,14px)}
|
||||
.hud-row{position:absolute;top:8px;left:8px;right:8px;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.hud-box{
|
||||
background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.18);
|
||||
padding:3px 8px;letter-spacing:.5px;color:#fff;text-shadow:0 1px 0 #000;
|
||||
}
|
||||
.hud-box#hud-save.on{color:#d9b3ff;border-color:rgba(176,107,255,.7);box-shadow:0 0 8px rgba(176,107,255,.35)}
|
||||
#boss-bar{position:absolute;top:38px;left:50%;transform:translateX(-50%);width:60%;min-width:220px}
|
||||
.boss-name{text-align:center;font-size:clamp(10px,1.6vh,15px);letter-spacing:3px;color:var(--red2);text-shadow:0 2px 0 #000;margin-bottom:3px}
|
||||
.boss-track{height:12px;background:#22222e;border:2px solid #000;box-shadow:0 0 0 1px #555}
|
||||
.boss-fill{height:100%;width:100%;background:linear-gradient(#ff6b8a,#c01a3a);transition:width .12s linear}
|
||||
#toast{
|
||||
position:absolute;left:50%;top:34%;transform:translateX(-50%);
|
||||
background:rgba(0,0,0,.72);border:1px solid #fff3;padding:8px 16px;
|
||||
font-size:clamp(11px,2vh,18px);color:#fff;opacity:0;transition:opacity .2s;max-width:80%;text-align:center;line-height:1.5;
|
||||
}
|
||||
#toast.show{opacity:1}
|
||||
|
||||
/* ---------- Screens ---------- */
|
||||
.screen{
|
||||
position:absolute;inset:0;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;gap:2vh;padding:3vh 3vw;
|
||||
background:linear-gradient(#0a0a12ee,#0a0a12f6);
|
||||
backdrop-filter:blur(2px);
|
||||
font-size:clamp(10px,1.6vh,15px);
|
||||
}
|
||||
.screen.hidden{display:none}
|
||||
#hud.hidden,#boss-bar.hidden{display:none}
|
||||
.hidden{display:none !important} /* 通用:任何元素加 hidden 类即隐藏(如自制页「管理地图」按钮) */
|
||||
|
||||
.title-wrap{text-align:center;margin-bottom:1vh}
|
||||
.title-sub{color:var(--dim);letter-spacing:6px;font-size:.75em}
|
||||
.title-main{
|
||||
font-size:clamp(22px,5.6vh,54px);line-height:1.05;letter-spacing:2px;
|
||||
text-shadow:3px 3px 0 #000, 0 0 24px rgba(224,48,80,.35);
|
||||
}
|
||||
.title-main .red{color:var(--red);text-shadow:3px 3px 0 #000,0 0 20px rgba(224,48,80,.6)}
|
||||
.title-tag{margin-top:1vh;color:var(--gold);letter-spacing:2px;font-size:.9em}
|
||||
|
||||
.menu{display:flex;flex-direction:column;gap:8px;width:min(340px,80%)}
|
||||
.btn{
|
||||
background:var(--bg2);color:var(--fg);border:2px solid var(--line);
|
||||
padding:9px 14px;font-family:inherit;font-size:1em;letter-spacing:2px;
|
||||
cursor:pointer;transition:.12s;text-align:center;
|
||||
}
|
||||
.btn:hover{background:var(--red);border-color:var(--red2);color:#fff;transform:translateY(-1px)}
|
||||
.btn:active{transform:translateY(1px)}
|
||||
.btn.big{font-size:1.25em;padding:12px;background:var(--red);border-color:var(--red2)}
|
||||
.btn.big:hover{background:#ff4466}
|
||||
.btn.small{font-size:.8em;padding:6px;color:var(--dim)}
|
||||
.btn.tiny{font-size:.8em;padding:5px 10px}
|
||||
.btn.go{background:var(--green);border-color:#7dffb0;color:#04180d}
|
||||
.foot{position:absolute;bottom:10px;color:#5a5a70;font-size:.72em;text-align:center;width:100%;padding:0 12px}
|
||||
|
||||
/* ---------- Panels ---------- */
|
||||
.panel{
|
||||
background:var(--bg2);border:2px solid var(--line);
|
||||
width:min(760px,96%);max-height:94%;display:flex;flex-direction:column;
|
||||
padding:14px;gap:10px;overflow:hidden;
|
||||
}
|
||||
.panel.narrow{width:min(400px,92%)}
|
||||
.panel-head{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:6px}
|
||||
.head-btns{display:flex;gap:6px;align-items:center}
|
||||
.head-btns .btn.on{background:var(--red);border-color:var(--red2);color:#fff}
|
||||
.panel h2{font-size:1.3em;letter-spacing:3px}
|
||||
.panel h3{font-size:1em;letter-spacing:2px;color:var(--gold);margin-bottom:5px}
|
||||
|
||||
.tabs{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.tab{
|
||||
background:#1b1b26;border:1px solid var(--line);color:var(--dim);
|
||||
padding:4px 12px;cursor:pointer;font-size:.9em;letter-spacing:1px;font-family:inherit;
|
||||
}
|
||||
.tab.on{background:var(--red);border-color:var(--red2);color:#fff}
|
||||
|
||||
.level-grid{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
|
||||
gap:8px;overflow-y:auto;padding:4px 2px;flex:1;align-content:start;
|
||||
}
|
||||
.lv-card{
|
||||
background:#1a1a25;border:1px solid var(--line);padding:8px;cursor:pointer;
|
||||
transition:.12s;position:relative;text-align:left;
|
||||
}
|
||||
.lv-card:hover{border-color:var(--red2);background:#24243a;transform:translateY(-2px)}
|
||||
.lv-card .idx{color:#55556a;font-size:.75em}
|
||||
.lv-card .nm{font-size:1.05em;margin:2px 0 5px;letter-spacing:1px}
|
||||
.lv-card .meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;font-size:.78em;color:var(--dim)}
|
||||
.lv-card .st{margin-top:5px;font-size:.78em;color:var(--dim)}
|
||||
.lv-card.cleared{border-color:#2f6b45}
|
||||
.lv-card .chk{position:absolute;right:7px;top:6px;color:var(--green)}
|
||||
/* 自制页管理态:删除按钮 */
|
||||
.lv-card.manage{cursor:default;border-color:var(--red2)}
|
||||
.lv-card.manage:hover{transform:none;border-color:var(--red);background:#241a22}
|
||||
.lv-del{
|
||||
position:absolute;right:6px;top:6px;width:22px;height:22px;padding:0;
|
||||
background:#3a1420;border:1px solid var(--red2);color:var(--red);
|
||||
font-family:inherit;font-size:.8em;line-height:1;cursor:pointer;border-radius:2px;
|
||||
}
|
||||
.lv-del:hover{background:var(--red);color:#fff;border-color:var(--red)}
|
||||
.tag{padding:1px 6px;border:1px solid;font-size:.85em}
|
||||
.t-jump{color:#ff8a5c;border-color:#ff8a5c}
|
||||
.t-endur{color:#4ad0ff;border-color:#4ad0ff}
|
||||
.t-boss{color:#ff5c7a;border-color:#ff5c7a}
|
||||
.t-troll{color:#c08aff;border-color:#c08aff}
|
||||
.t-custom{color:#ffc63a;border-color:#ffc63a}
|
||||
.legend{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:7px;color:var(--dim);font-size:.85em;align-items:center}
|
||||
|
||||
.clear-title{text-align:center;font-size:2.4em;color:var(--gold);letter-spacing:6px;text-shadow:3px 3px 0 #000}
|
||||
.result-body{display:flex;flex-direction:column;gap:5px}
|
||||
.rline{display:flex;justify-content:space-between;border-bottom:1px dotted var(--line);padding:4px 2px}
|
||||
.rline span{color:var(--dim)}
|
||||
.rbest{color:var(--green);font-size:.85em;text-align:center;margin-top:4px}
|
||||
|
||||
/* ---------- Editor ---------- */
|
||||
.editor-wrap{display:flex;gap:10px;flex:1;overflow:hidden}
|
||||
.palette{width:112px;display:flex;flex-direction:column;gap:3px;overflow-y:auto;border-right:1px solid var(--line);padding-right:6px}
|
||||
.pal{
|
||||
display:flex;align-items:center;gap:5px;background:#1b1b26;border:1px solid var(--line);
|
||||
padding:2px 5px;cursor:pointer;font-size:.75em;color:var(--dim);
|
||||
}
|
||||
.pal:hover{border-color:#888}
|
||||
.pal.on{border-color:var(--gold);color:#fff;background:#2b2b3c}
|
||||
.pal-cv{width:26px;height:26px;flex:none;border:1px solid #000;background:#101018;image-rendering:pixelated}
|
||||
.pal .ch{font-weight:bold;color:var(--fg);width:12px;text-align:center}
|
||||
.editor-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto}
|
||||
.editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
|
||||
.editor-bar input,.editor-bar select{
|
||||
background:#111119;border:1px solid var(--line);color:var(--fg);
|
||||
font-family:inherit;font-size:.85em;padding:4px 6px;
|
||||
}
|
||||
#ed-name{width:110px}#ed-w{width:52px}#ed-h{width:52px}
|
||||
.ed-wh{display:inline-flex;align-items:center;gap:3px;font-size:.78em;color:var(--dim)}
|
||||
.ed-wh input{width:48px}
|
||||
canvas#ed-canvas{
|
||||
width:800px;height:auto;background:#0d0d16;border:1px solid var(--line);
|
||||
image-rendering:pixelated;cursor:crosshair;display:block;
|
||||
}
|
||||
.editor-tip{color:var(--dim);font-size:.78em}
|
||||
.editor-side{width:170px;display:flex;flex-direction:column;gap:5px;border-left:1px solid var(--line);padding-left:8px;overflow-y:auto}
|
||||
#my-levels{display:flex;flex-direction:column;gap:4px}
|
||||
.my-lv{background:#1b1b26;border:1px solid var(--line);padding:4px 6px;font-size:.78em;cursor:pointer;display:flex;justify-content:space-between;gap:4px}
|
||||
.my-lv:hover{border-color:var(--gold)}
|
||||
.my-lv.on{border-color:var(--gold);background:#2b2b3c}
|
||||
.my-lv .x{color:var(--red)}
|
||||
.face-btns{display:flex;gap:4px;flex-wrap:wrap}
|
||||
#ed-faces{display:flex;flex-direction:column;gap:4px;max-height:160px;overflow-y:auto}
|
||||
.face-row{background:#1b1b26;border:1px solid var(--line);padding:3px 5px;font-size:.78em;cursor:pointer;display:flex;flex-direction:column;gap:2px}
|
||||
.face-row:hover{border-color:var(--gold)}
|
||||
.face-row.on{border-color:var(--gold);background:#2b2b3c}
|
||||
.face-row .face-name{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:2px 4px;width:100%}
|
||||
.face-row .face-wh{color:var(--dim);font-size:.9em}
|
||||
/* 属性面板(选中方块) */
|
||||
#ed-props{display:flex;flex-direction:column;gap:5px;max-height:36vh;overflow-y:auto;border:1px solid var(--line);background:#111119;padding:6px}
|
||||
.prop-empty{color:var(--dim);font-size:.76em;line-height:1.5}
|
||||
.prop-head{color:var(--gold);font-weight:bold;font-size:.92em;letter-spacing:1px;margin-bottom:2px}
|
||||
.prop-row{display:flex;flex-direction:column;gap:3px;background:#1b1b26;border:1px solid var(--line);padding:4px 5px;font-size:.78em}
|
||||
.prop-lab{color:var(--fg)}
|
||||
.prop-note{color:var(--dim);font-size:.92em}
|
||||
.prop-ctl{display:flex;gap:4px}
|
||||
.prop-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:3px 4px;width:100%}
|
||||
.prop-ctl .prop-in{width:auto;flex:1}
|
||||
|
||||
/* 「保存」去向提示 */
|
||||
.save-state{color:var(--dim);font-size:.78em;white-space:nowrap;align-self:center}
|
||||
.save-state.on{color:var(--gold)}
|
||||
|
||||
/* 导入地图弹窗 */
|
||||
.modal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,4,10,.72);padding:4vh 4vw}
|
||||
.modal.hidden{display:none}
|
||||
.modal-panel{background:var(--bg2);border:2px solid var(--line);width:min(380px,94%);max-height:86vh;display:flex;flex-direction:column;gap:8px;padding:14px}
|
||||
.modal-panel h3{font-size:1.05em;letter-spacing:2px;color:var(--gold)}
|
||||
.modal-tip{color:var(--dim);font-size:.78em;line-height:1.5}
|
||||
#ed-import-list{display:flex;flex-direction:column;gap:4px;overflow-y:auto}
|
||||
.imp-row{background:#1b1b26;border:1px solid var(--line);padding:6px 8px;font-size:.84em;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
||||
.imp-row:hover{border-color:var(--gold)}
|
||||
.imp-row.on{border-color:var(--gold);background:#2b2b3c}
|
||||
.imp-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.imp-meta{color:var(--dim);font-size:.9em;white-space:nowrap}
|
||||
.imp-empty{color:var(--dim);font-size:.8em;padding:10px 2px}
|
||||
.modal-btns{display:flex;justify-content:flex-end;gap:6px}
|
||||
|
||||
/* ---------- 设置界面 ---------- */
|
||||
.title-user{color:var(--dim);font-size:.85em;letter-spacing:2px;margin-top:1vh}
|
||||
.set-body{display:flex;flex-direction:column;gap:14px;overflow-y:auto;padding:2px}
|
||||
.set-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.set-row.col{flex-direction:column;align-items:stretch;gap:6px;justify-content:flex-start}
|
||||
.set-lab{color:var(--gold);letter-spacing:1px;font-size:.95em;min-width:64px}
|
||||
.set-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:6px 8px;min-width:200px}
|
||||
.set-in:focus{outline:none;border-color:var(--gold)}
|
||||
.set-hint{color:var(--dim);font-size:.78em}
|
||||
.set-btns{margin-top:2px}
|
||||
/* 键盘按键映射面板(电脑端) */
|
||||
.set-keys{display:flex;flex-direction:column;gap:8px}
|
||||
.krow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.kact{color:var(--fg);min-width:104px;font-size:.92em}
|
||||
.kkeys{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.kchip{
|
||||
position:relative;background:#1a1a26;border:1px solid var(--line);color:var(--fg);
|
||||
font-family:inherit;font-size:.88em;padding:5px 22px 5px 10px;border-radius:7px;cursor:pointer;
|
||||
transition:border-color .1s,background .1s;
|
||||
}
|
||||
.kchip:hover{border-color:var(--gold)}
|
||||
.kchip.listen{border-color:var(--gold);background:rgba(255,198,58,.16);box-shadow:0 0 0 2px rgba(255,198,58,.25)}
|
||||
.kchip .krm{
|
||||
position:absolute;top:-7px;right:-7px;width:16px;height:16px;border-radius:50%;
|
||||
background:#c01a3a;color:#fff;font-size:.62em;line-height:16px;text-align:center;
|
||||
border:1px solid #000;cursor:pointer;
|
||||
}
|
||||
.kadd{
|
||||
background:transparent;border:1px dashed var(--line);color:var(--dim);
|
||||
font-family:inherit;font-size:.82em;padding:5px 9px;border-radius:7px;cursor:pointer;
|
||||
}
|
||||
.kadd:hover{color:var(--gold);border-color:var(--gold)}
|
||||
.klisten{color:var(--gold);font-size:.8em;animation:blink 1s infinite}
|
||||
.klisten.bad{color:#ff6b6b;animation:none;font-weight:bold}
|
||||
.kwarb{
|
||||
width:100%;box-sizing:border-box;margin:0 0 10px;padding:8px 12px;border-radius:8px;
|
||||
background:rgba(192,26,58,.16);border:1px solid #c01a3a;color:#ff8a8a;font-size:.86em;
|
||||
}
|
||||
@keyframes blink{50%{opacity:.4}}
|
||||
/* 手机手柄预览区(与舞台同比例),按钮绝对定位可拖拽 */
|
||||
.touch-preview{
|
||||
position:relative;width:100%;aspect-ratio:800/608;
|
||||
background:#0d0d16;border:1px solid var(--line);overflow:hidden;max-height:46vh;
|
||||
}
|
||||
.tp-tip{
|
||||
position:absolute;top:6px;left:50%;transform:translateX(-50%);
|
||||
color:var(--dim);font-size:.72em;background:rgba(0,0,0,.45);
|
||||
padding:2px 8px;border-radius:6px;pointer-events:none;z-index:3;white-space:nowrap;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:#111}
|
||||
::-webkit-scrollbar-thumb{background:#333;}
|
||||
|
||||
@media (max-width:720px){
|
||||
.editor-wrap{flex-direction:column;overflow-y:auto}
|
||||
.palette{width:100%;flex-direction:row;flex-wrap:wrap;border-right:none;border-bottom:1px solid var(--line);padding-bottom:6px}
|
||||
.editor-side{width:100%;border-left:none;border-top:1px solid var(--line);padding-top:8px}
|
||||
}
|
||||
|
||||
/* ---------- 移动端虚拟手柄(参考 IWBT Creator 触屏布局) ---------- */
|
||||
/* 桌面端兜底:只要 <html> 没有 .touch 类(即未检测到移动端/触屏),无论如何都不显示按钮 */
|
||||
html:not(.touch) #touch{display:none !important}
|
||||
#touch{
|
||||
position:absolute;inset:0;z-index:25;
|
||||
display:flex;justify-content:space-between;align-items:flex-end;
|
||||
padding:12px 12px max(14px, env(safe-area-inset-bottom));
|
||||
pointer-events:none; /* 仅按钮可点,中间区域穿透到画布 */
|
||||
touch-action:none;
|
||||
}
|
||||
#touch.hidden{display:none}
|
||||
|
||||
.tc-left,.tc-right{display:flex;gap:14px;align-items:flex-end;pointer-events:none}
|
||||
.tc-right{flex-direction:column;align-items:flex-end}
|
||||
.tc-top{display:flex;gap:10px;margin-bottom:10px}
|
||||
.tc-mid{display:flex;gap:12px;align-items:flex-end;margin-bottom:10px}
|
||||
|
||||
.tc-btn{
|
||||
pointer-events:auto;-webkit-user-select:none;user-select:none;touch-action:none;
|
||||
position:absolute;transform:translate(-50%,-50%);
|
||||
background:rgba(18,18,28,.52);border:2px solid rgba(255,255,255,.28);
|
||||
color:#fff;font-family:inherit;display:flex;align-items:center;justify-content:center;
|
||||
flex-direction:column;line-height:1;backdrop-filter:blur(2px);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.35);
|
||||
transition:transform .05s, background .1s, filter .1s;
|
||||
}
|
||||
.tc-btn span{font-size:1.5em}
|
||||
.tc-btn .lbl{font-size:.6em;letter-spacing:1px;opacity:.85;margin-top:2px}
|
||||
.tc-btn.on,.tc-btn:active{transform:translate(-50%,-50%) scale(.93);background:rgba(224,48,80,.6);border-color:#ff5c7a}
|
||||
|
||||
/* 设置界面预览里的缩放手柄(真实手柄不挂此元素) */
|
||||
.tc-rsz{
|
||||
position:absolute;right:-7px;bottom:-7px;width:16px;height:16px;border-radius:50%;
|
||||
background:var(--gold);border:2px solid #000;cursor:nwse-resize;opacity:.92;touch-action:none;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
.tc-move{width:62px;height:62px;border-radius:14px}
|
||||
.tc-item{width:54px;height:54px;border-radius:50%}
|
||||
.tc-sm{width:42px;height:42px;border-radius:11px;font-size:1em}
|
||||
|
||||
.tc-jump{
|
||||
width:94px;height:94px;border-radius:50%;margin-top:4px;
|
||||
background:radial-gradient(circle at 35% 30%, #ff7088, #c01a3a);
|
||||
border:3px solid #ffd0da;box-shadow:0 6px 18px rgba(192,26,58,.5);
|
||||
font-size:1.9em;font-weight:bold;color:#fff;
|
||||
}
|
||||
.tc-jump.on,.tc-jump:active{transform:translate(-50%,-50%) scale(.92);filter:brightness(1.12)}
|
||||
|
||||
/* 未持枪时射击键置灰 */
|
||||
#tc-gun.off{opacity:.4;filter:grayscale(1);background:rgba(18,18,28,.4)}
|
||||
@@ -0,0 +1,25 @@
|
||||
// One-off: export all built-in levels to JSON for the Flutter port.
|
||||
import { LEVELS, TYPE_LABEL, TYPE_CLASS } from './js/levels/index.js';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
// Sanity: ensure serializable and report shapes.
|
||||
function deepCheck(v, path) {
|
||||
if (v === null || v === undefined) return;
|
||||
if (typeof v === 'function') throw new Error('function at ' + path);
|
||||
if (typeof v === 'object') {
|
||||
if (Array.isArray(v)) { v.forEach((e, i) => deepCheck(e, path + '[' + i + ']')); }
|
||||
else { for (const k of Object.keys(v)) deepCheck(v[k], path + '.' + k); }
|
||||
}
|
||||
}
|
||||
for (const l of LEVELS) deepCheck(l, l.id || '?');
|
||||
|
||||
const out = {
|
||||
levels: LEVELS,
|
||||
typeLabel: TYPE_LABEL,
|
||||
typeClass: TYPE_CLASS,
|
||||
};
|
||||
writeFileSync('levels_dump.json', JSON.stringify(out));
|
||||
console.log('OK levels=', LEVELS.length, 'bytes=', JSON.stringify(out).length);
|
||||
for (const l of LEVELS) {
|
||||
console.log(' -', l.id, l.name, 'type=' + l.type, 'w=' + l.width, 'rows=' + (l.grid ? l.grid.length : '?'));
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>I WANNA BE THE CREATOR — Fan Project</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="stage">
|
||||
<canvas id="game" width="800" height="608"></canvas>
|
||||
|
||||
<!-- ===== HUD ===== -->
|
||||
<div id="hud" class="hidden">
|
||||
<div class="hud-row">
|
||||
<div class="hud-box" id="hud-level">—</div>
|
||||
<div class="hud-box" id="hud-deaths">DEATH 0</div>
|
||||
<div class="hud-box" id="hud-time">0:00.00</div>
|
||||
<div class="hud-box" id="hud-apples" style="display:none">🍎 0/0</div>
|
||||
<div class="hud-box" id="hud-save" style="display:none">SAVE 0/0</div>
|
||||
</div>
|
||||
<div id="boss-bar" class="hidden">
|
||||
<div class="boss-name" id="boss-name">BOSS</div>
|
||||
<div class="boss-track"><div class="boss-fill" id="boss-fill"></div></div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MAIN MENU ===== -->
|
||||
<div class="screen" id="scr-title">
|
||||
<div class="title-wrap">
|
||||
<div class="title-sub">FAN-MADE TRIBUTE PROJECT</div>
|
||||
<h1 class="title-main">I WANNA BE<br>THE <span class="red">CREATOR</span></h1>
|
||||
<div class="title-tag">跳刺 · 耐久 · BOSS · 坑向 — 23 个内置关卡</div>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<button class="btn big" data-act="levels">关卡选择</button>
|
||||
<button class="btn" data-act="editor">自制关卡</button>
|
||||
<button class="btn" data-act="settings">设置</button>
|
||||
</div>
|
||||
<div id="title-user" class="title-user"></div>
|
||||
<div class="foot">非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator · 素材与代码原创</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== LEVEL SELECT ===== -->
|
||||
<div class="screen hidden" id="scr-levels">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>关卡选择</h2>
|
||||
<div class="head-btns">
|
||||
<button class="btn tiny hidden" id="manage-btn" data-act="manage">管理地图</button>
|
||||
<button class="btn tiny" data-act="back">返回</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs" id="level-tabs"></div>
|
||||
<div class="level-grid" id="level-grid"></div>
|
||||
<div class="legend">
|
||||
<span class="tag t-jump">跳刺</span>
|
||||
<span class="tag t-endur">耐久</span>
|
||||
<span class="tag t-boss">BOSS</span>
|
||||
<span class="tag t-troll">坑向</span>
|
||||
<span class="tag t-custom">自制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== PAUSE ===== -->
|
||||
<div class="screen hidden" id="scr-pause">
|
||||
<div class="panel narrow">
|
||||
<div class="panel-head"><h2>暂停</h2></div>
|
||||
<div class="menu">
|
||||
<button class="btn" data-act="resume">继续</button>
|
||||
<button class="btn" data-act="restart">从头再来(清空存档点)</button>
|
||||
<button class="btn" data-act="quit">返回关卡选择</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== RESULT ===== -->
|
||||
<div class="screen hidden" id="scr-result">
|
||||
<div class="panel narrow">
|
||||
<h2 class="clear-title">CLEAR!</h2>
|
||||
<div class="result-body">
|
||||
<div class="rline"><span>关卡</span><b id="r-name">—</b></div>
|
||||
<div class="rline"><span>用时</span><b id="r-time">—</b></div>
|
||||
<div class="rline"><span>死亡</span><b id="r-death">—</b></div>
|
||||
<div class="rline"><span>苹果</span><b id="r-apple">—</b></div>
|
||||
<div class="rbest" id="r-best"></div>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<button class="btn" data-act="next">下一关</button>
|
||||
<button class="btn" data-act="retry">再挑战</button>
|
||||
<button class="btn" data-act="quit">关卡选择</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SETTINGS ===== -->
|
||||
<div class="screen hidden" id="scr-settings">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>设置</h2>
|
||||
<button class="btn tiny" data-act="back">返回</button>
|
||||
</div>
|
||||
<div class="set-body">
|
||||
<div class="set-row">
|
||||
<label class="set-lab" for="set-username">用户名</label>
|
||||
<input id="set-username" class="set-in" maxlength="16" placeholder="无名氏">
|
||||
<span class="set-hint">显示在标题界面</span>
|
||||
</div>
|
||||
<div class="set-row col" id="set-keys-row">
|
||||
<label class="set-lab">键盘按键</label>
|
||||
<span class="set-hint">点击按键后按下新键即可重绑;同一动作可绑定多个键,点 × 删除</span>
|
||||
<div id="set-keys" class="set-keys"></div>
|
||||
</div>
|
||||
<div class="set-row col" id="set-touch-row">
|
||||
<label class="set-lab">手机操作按钮</label>
|
||||
<span class="set-hint">拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)</span>
|
||||
<div id="touch-preview" class="touch-preview">
|
||||
<div class="tp-tip">拖动按钮移动位置 · 拖动右下角圆点缩放大小</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-btns set-btns">
|
||||
<button class="btn tiny" data-act="set-reset">恢复默认</button>
|
||||
<button class="btn tiny go" data-act="set-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== EDITOR ===== -->
|
||||
<div class="screen hidden" id="scr-editor">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>自制关卡</h2>
|
||||
<button class="btn tiny" data-act="back">返回</button>
|
||||
</div>
|
||||
<div class="editor-wrap">
|
||||
<div class="palette" id="palette"></div>
|
||||
<div class="editor-main">
|
||||
<div class="editor-bar">
|
||||
<input id="ed-name" placeholder="地图名" value="MY MAP">
|
||||
<label class="ed-wh">宽<input id="ed-w" type="number" value="25" min="12" max="160"></label>
|
||||
<label class="ed-wh">高<input id="ed-h" type="number" value="19" min="8" max="80"></label>
|
||||
<button class="btn tiny" data-act="ed-resize">调整</button>
|
||||
<button class="btn tiny" data-act="ed-fill">铺底</button>
|
||||
<button class="btn tiny" data-act="ed-clear">清空</button>
|
||||
<button class="btn tiny" data-act="ed-import">导入地图</button>
|
||||
<button class="btn tiny" data-act="ed-test">试玩</button>
|
||||
<button class="btn tiny go" data-act="ed-save">保存</button>
|
||||
<span id="ed-savestate" class="save-state">→ 新地图</span>
|
||||
</div>
|
||||
<canvas id="ed-canvas" width="800" height="608"></canvas>
|
||||
<div class="editor-tip">左键放置 / 右键擦除,滚轮水平平移。<b>不会覆盖已有方块</b>——放在空格才生效,要换掉先擦除。选「⌖ 查看属性」再点方块可只查看不修改。<b>刺</b>只有一个,放好后在右侧「属性」面板调<b>方向(上/下/左/右)</b>;传送门 <b>W</b>、提示牌 <b>T</b>、炮台 <b>O</b>、摄像头 <b>C</b>、BOSS <b>B</b> 的属性也都在这里设置。每面至少 1 个 <b>S</b>(出生点) 与 1 个 <b>X</b>(终点)。</div>
|
||||
</div>
|
||||
<div class="editor-side">
|
||||
<h3>属性(选中方块)</h3>
|
||||
<div id="ed-props"><div class="prop-empty">点击画布上的方块,查看并设置它的属性</div></div>
|
||||
<h3>面(多面地图)</h3>
|
||||
<div class="face-btns">
|
||||
<button class="btn tiny" data-act="ed-addface">+ 新增面</button>
|
||||
<button class="btn tiny" data-act="ed-copyface">复制面</button>
|
||||
<button class="btn tiny" data-act="ed-delface">删除面</button>
|
||||
</div>
|
||||
<div id="ed-faces"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 导入地图选择 -->
|
||||
<div id="ed-importbox" class="modal hidden">
|
||||
<div class="modal-panel">
|
||||
<h3>导入地图</h3>
|
||||
<p class="modal-tip">从以前保存过的地图里选一个载入编辑器(会替换当前编辑内容)</p>
|
||||
<div id="ed-import-list"></div>
|
||||
<div class="modal-btns">
|
||||
<button class="btn tiny" data-act="ed-import-cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TOUCH CONTROLS(移动端虚拟手柄,参考 IWBT Creator 触屏布局) ===== -->
|
||||
<div id="touch" class="hidden">
|
||||
<!-- 左侧:左右移动 -->
|
||||
<div class="tc-left">
|
||||
<button class="tc-btn tc-move" data-touch="left" aria-label="左移"><span>◀</span></button>
|
||||
<button class="tc-btn tc-move" data-touch="right" aria-label="右移"><span>▶</span></button>
|
||||
</div>
|
||||
<!-- 右侧:跳跃 + 各种道具 -->
|
||||
<div class="tc-right">
|
||||
<div class="tc-top">
|
||||
<button class="tc-btn tc-sm" data-touch="pause" aria-label="暂停">❚❚</button>
|
||||
<button class="tc-btn tc-sm" data-touch="suicide" aria-label="重生">↺</button>
|
||||
</div>
|
||||
<div class="tc-mid">
|
||||
<button class="tc-btn tc-item" id="tc-gun" data-touch="shoot" aria-label="射击"><span>🔫</span><span class="lbl">射击</span></button>
|
||||
<button class="tc-btn tc-item" data-touch="down" aria-label="下蹲/下穿"><span>▼</span><span class="lbl">下蹲</span></button>
|
||||
</div>
|
||||
<button class="tc-btn tc-jump" data-touch="jump" aria-label="跳跃"><span>跳</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
// 纯 WebAudio 合成音效,无外部资源
|
||||
let ctx = null, master = null;
|
||||
export const Audio2 = { enabled: true, volume: 0.5 };
|
||||
|
||||
function ac() {
|
||||
if (!ctx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
ctx = new AC();
|
||||
master = ctx.createGain();
|
||||
master.gain.value = Audio2.volume;
|
||||
master.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
return ctx;
|
||||
}
|
||||
export function unlockAudio() { ac(); }
|
||||
|
||||
function tone({ f = 440, f2 = null, d = 0.1, type = 'square', vol = 0.25, delay = 0, decay = 0 }) {
|
||||
if (!Audio2.enabled) return;
|
||||
const c = ac(); if (!c) return;
|
||||
const t0 = c.currentTime + delay;
|
||||
const o = c.createOscillator();
|
||||
const g = c.createGain();
|
||||
o.type = type;
|
||||
o.frequency.setValueAtTime(f, t0);
|
||||
if (f2 !== null) o.frequency.exponentialRampToValueAtTime(Math.max(20, f2), t0 + d);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(vol, t0 + 0.008);
|
||||
const end = t0 + d + decay;
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, end);
|
||||
o.connect(g); g.connect(master);
|
||||
o.start(t0); o.stop(end + 0.02);
|
||||
}
|
||||
|
||||
function noise({ d = 0.2, vol = 0.25, delay = 0, hp = 400 }) {
|
||||
if (!Audio2.enabled) return;
|
||||
const c = ac(); if (!c) return;
|
||||
const t0 = c.currentTime + delay;
|
||||
const len = Math.floor(c.sampleRate * d);
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len);
|
||||
const src = c.createBufferSource(); src.buffer = buf;
|
||||
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = hp;
|
||||
const g = c.createGain(); g.gain.value = vol;
|
||||
src.connect(f); f.connect(g); g.connect(master);
|
||||
src.start(t0);
|
||||
}
|
||||
|
||||
export const sfx = {
|
||||
jump() { tone({ f: 380, f2: 700, d: 0.09, type: 'square', vol: 0.16 }); },
|
||||
djump() { tone({ f: 520, f2: 900, d: 0.09, type: 'square', vol: 0.14 }); },
|
||||
shoot() { tone({ f: 900, f2: 260, d: 0.07, type: 'square', vol: 0.14 }); noise({ d: 0.05, vol: 0.06, hp: 2000 }); },
|
||||
death() { tone({ f: 620, f2: 60, d: 0.55, type: 'sawtooth', vol: 0.26 });
|
||||
noise({ d: 0.35, vol: 0.16, hp: 300, delay: 0.02 }); },
|
||||
save() { tone({ f: 700, d: 0.08, type: 'triangle', vol: 0.2 });
|
||||
tone({ f: 1050, d: 0.13, type: 'triangle', vol: 0.18, delay: 0.07 }); },
|
||||
goal() { [523, 659, 784, 1047].forEach((f, i) =>
|
||||
tone({ f, d: 0.16, type: 'triangle', vol: 0.2, delay: i * 0.1 })); },
|
||||
warp() { tone({ f: 300, f2: 1400, d: 0.2, type: 'sine', vol: 0.18 }); },
|
||||
hit() { tone({ f: 200, f2: 90, d: 0.08, type: 'square', vol: 0.16 }); },
|
||||
bossHit() { tone({ f: 260, f2: 140, d: 0.1, type: 'sawtooth', vol: 0.14 }); },
|
||||
bossDie() { [400, 330, 260, 180, 110].forEach((f, i) =>
|
||||
tone({ f, d: 0.22, type: 'sawtooth', vol: 0.22, delay: i * 0.11 }));
|
||||
noise({ d: 0.8, vol: 0.2, hp: 150, delay: 0.1 }); },
|
||||
menu() { tone({ f: 620, d: 0.05, type: 'square', vol: 0.12 }); },
|
||||
vanish() { tone({ f: 700, f2: 300, d: 0.12, type: 'sine', vol: 0.1 }); },
|
||||
troll() { tone({ f: 180, f2: 900, d: 0.3, type: 'sine', vol: 0.18 }); },
|
||||
splash() { noise({ d: 0.18, vol: 0.12, hp: 800 }); },
|
||||
};
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
// BOSS 战系统:多阶段弹幕 / 冲撞 / 刺雨
|
||||
import { clamp, sgn, rand, randInt, overlap } from './util.js';
|
||||
import { sfx } from './audio.js';
|
||||
import { TILE } from './util.js';
|
||||
|
||||
const DEFS = {
|
||||
guy: { hp: 42, w: 64, h: 74, name: 'THE GUY' },
|
||||
mecha: { hp: 62, w: 78, h: 62, name: 'MECHA KID' },
|
||||
devil: { hp: 84, w: 74, h: 90, name: 'THE DEVIL' },
|
||||
};
|
||||
|
||||
export class Boss {
|
||||
constructor(level, e) {
|
||||
const d = DEFS[e.kind] || DEFS.guy;
|
||||
this.level = level;
|
||||
this.kind = e.kind;
|
||||
this.name = d.name;
|
||||
this.x = e.x; this.y = e.y;
|
||||
this.w = d.w; this.h = d.h;
|
||||
this.maxHp = e.hp || d.hp;
|
||||
this.hp = this.maxHp;
|
||||
this.flash = 0;
|
||||
this.dead = false;
|
||||
this.deathT = 0;
|
||||
this.bodyKill = true;
|
||||
this.t = 0;
|
||||
this.phase = 1;
|
||||
this.vx = 0; this.vy = 0;
|
||||
this.state = 'idle';
|
||||
this.stateT = 0;
|
||||
// BOSS 贴地活动:保证玩家在地面平射就能命中
|
||||
this.bounds = {
|
||||
x0: 40, x1: level.pxW - 40 - this.w,
|
||||
y0: 40, y1: level.pxH - 32 - this.h, // 底部贴地
|
||||
};
|
||||
this.homeY = clamp(e.y, this.bounds.y0, this.bounds.y1);
|
||||
this.y = this.homeY;
|
||||
this.homeX = e.x;
|
||||
this.wing = 0;
|
||||
}
|
||||
|
||||
get rect() { return { x: this.x, y: this.y, w: this.w, h: this.h }; }
|
||||
|
||||
shoot(level, x, y, vx, vy, col = '#ff5c7a', big = false) {
|
||||
level.ebullets.push({ x: x - 4, y: y - 4, w: big ? 12 : 8, h: big ? 12 : 8, vx, vy, dead: false, col, big, life: 500 });
|
||||
}
|
||||
|
||||
hit(dmg = 1) {
|
||||
if (this.dead || this.flash > 6) return;
|
||||
this.hp -= dmg;
|
||||
this.flash = 10;
|
||||
sfx.bossHit();
|
||||
this.level.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 6,
|
||||
{ colors: ['#fff', '#ffc63a'], speed: 2.6, life: 18 });
|
||||
const r = this.hp / this.maxHp;
|
||||
this.phase = r > 0.6 ? 1 : r > 0.3 ? 2 : 3;
|
||||
if (this.hp <= 0) this.die();
|
||||
}
|
||||
|
||||
die() {
|
||||
this.dead = true; this.hp = 0; this.bodyKill = false;
|
||||
sfx.bossDie();
|
||||
this.level.shake = 16;
|
||||
this.level.particles.burst(this.x + this.w / 2, this.y + this.h / 2,
|
||||
{ n: 40, colors: ['#ff5c7a', '#ffc63a', '#fff', '#e03050'], speed: 5, life: 60, size: 4 });
|
||||
}
|
||||
|
||||
update(dt, player) {
|
||||
if (this.dead) {
|
||||
this.deathT++;
|
||||
if (this.deathT % 8 === 0 && this.deathT < 90) {
|
||||
this.level.particles.burst(this.x + rand(0, this.w), this.y + rand(0, this.h),
|
||||
{ n: 6, colors: ['#ffc63a', '#ff5c7a', '#fff'], speed: 3, life: 30, size: 3 });
|
||||
sfx.hit();
|
||||
}
|
||||
if (this.deathT === 90) { if (this.onDefeat) this.onDefeat(); }
|
||||
return;
|
||||
}
|
||||
this.t++;
|
||||
this.stateT++;
|
||||
if (this.flash > 0) this.flash--;
|
||||
|
||||
// 子类型行为
|
||||
if (this.kind === 'guy') this.updateGuy(player);
|
||||
else if (this.kind === 'mecha') this.updateMecha(player);
|
||||
else this.updateDevil(player);
|
||||
|
||||
this.x += this.vx; this.y += this.vy;
|
||||
this.x = clamp(this.x, this.bounds.x0, Math.max(this.bounds.x0, this.bounds.x1));
|
||||
this.y = clamp(this.y, this.bounds.y0, Math.max(this.bounds.y0, this.bounds.y1));
|
||||
|
||||
// 被子弹击中
|
||||
for (const b of this.level.bullets) {
|
||||
if (b.dead) continue;
|
||||
if (overlap(b, this.rect)) {
|
||||
b.dead = true;
|
||||
this.hit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- THE GUY:飘移 + 扇形弹 + 砸地 ----------
|
||||
updateGuy(player) {
|
||||
const L = this.level;
|
||||
if (this.phase === 1) {
|
||||
this.vx = Math.sin(this.t / 60) * 1.5;
|
||||
this.y = this.homeY + Math.sin(this.t / 45) * 8;
|
||||
if (this.t % 96 === 0) {
|
||||
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const a = Math.atan2(player.cy - cy, player.cx - cx) + i * 0.28;
|
||||
this.shoot(L, cx, cy, Math.cos(a) * 2.8, Math.sin(a) * 2.8);
|
||||
}
|
||||
sfx.shoot();
|
||||
}
|
||||
} else {
|
||||
// 追踪 + 砸地
|
||||
const dir = sgn(player.cx - (this.x + this.w / 2));
|
||||
if (this.state === 'idle') {
|
||||
this.vx = dir * 1.9;
|
||||
this.y = this.homeY + Math.sin(this.t / 30) * 6;
|
||||
if (this.stateT > 70) { this.state = 'slam'; this.stateT = 0; this.vy = 0; }
|
||||
} else if (this.state === 'slam') {
|
||||
this.vx *= 0.9;
|
||||
this.vy = Math.min(this.vy + 0.7, 11);
|
||||
if (this.y + this.h >= this.bounds.y1 + this.h * 0.2 || this.y >= this.homeY + 150) {
|
||||
// 落地冲击:左右各飞出 3 发
|
||||
this.state = 'idle'; this.stateT = 0; this.vy = -3;
|
||||
L.shake = 10; sfx.hit();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, -2.6 - i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
||||
this.shoot(L, this.x + this.w / 2, this.y + this.h - 6, 2.6 + i * 0.4, -1.2 - i * 0.15, '#ffc63a');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- MECHA KID:冲撞 + 刺雨 ----------
|
||||
updateMecha(player) {
|
||||
const L = this.level;
|
||||
if (this.state === 'idle') {
|
||||
this.vx *= 0.92;
|
||||
this.y = this.homeY + Math.sin(this.t / 40) * 5;
|
||||
if (this.stateT > 100 - this.phase * 18) {
|
||||
this.state = 'dash'; this.stateT = 0;
|
||||
this.vx = sgn(player.cx - (this.x + this.w / 2)) * (5 + this.phase * 0.8);
|
||||
sfx.shoot();
|
||||
}
|
||||
if (this.t % 150 === 0) {
|
||||
// 刺雨
|
||||
for (let i = 0; i < 4 + this.phase; i++) {
|
||||
const tx = randInt(1, L.w - 2);
|
||||
L.entities.push({
|
||||
type: 'dropspike', x: tx * TILE, y: -TILE, w: TILE, h: TILE,
|
||||
falling: true, vy: 0, homeY: -TILE, t: 0, temp: true,
|
||||
});
|
||||
}
|
||||
L.showToast('刺雨来了!', 80);
|
||||
sfx.troll();
|
||||
}
|
||||
} else if (this.state === 'dash') {
|
||||
this.vy = 0;
|
||||
if (this.x <= this.bounds.x0 + 1 || this.x >= this.bounds.x1 - 1 || this.stateT > 90) {
|
||||
this.state = 'idle'; this.stateT = 0; this.vx = 0;
|
||||
L.shake = 6;
|
||||
L.particles.spawn(this.x + this.w / 2, this.y + this.h / 2, 8,
|
||||
{ colors: ['#9ad8ff', '#fff'], speed: 3, life: 20 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- THE DEVIL:追踪弹 / 激光 / 环形弹幕 ----------
|
||||
updateDevil(player) {
|
||||
const L = this.level;
|
||||
this.wing += 0.16;
|
||||
const cx = this.x + this.w / 2, cy = this.y + this.h / 2;
|
||||
const p2 = this.phase;
|
||||
|
||||
// 移动
|
||||
if (p2 === 1) { this.vx = Math.sin(this.t / 70) * 1.6; this.y = this.homeY + Math.sin(this.t / 50) * 8; }
|
||||
else if (p2 === 2) { this.vx = Math.sin(this.t / 40) * 2.6; this.y = this.homeY + Math.sin(this.t / 28) * 11; }
|
||||
else { this.vx = Math.sin(this.t / 25) * 3.4; this.y = this.homeY + Math.sin(this.t / 20) * 14; }
|
||||
|
||||
if (p2 === 1) {
|
||||
if (this.t % 60 === 0) {
|
||||
const a = Math.atan2(player.cy - cy, player.cx - cx);
|
||||
for (let i = -1; i <= 1; i++)
|
||||
this.shoot(L, cx, cy, Math.cos(a + i * 0.2) * 3, Math.sin(a + i * 0.2) * 3, '#ff9a3a');
|
||||
sfx.shoot();
|
||||
}
|
||||
} else if (p2 === 2) {
|
||||
if (this.t % 110 === 0) { this.state = 'laser'; this.stateT = 0; }
|
||||
if (this.state === 'laser' && this.stateT < 40 && this.stateT % 5 === 0) {
|
||||
// 横向激光弹幕(从上往下扫)
|
||||
const yy = 40 + (this.stateT / 40) * (L.pxH - 90);
|
||||
this.shoot(L, 8, yy, 6.5, 0, '#ff4060', true);
|
||||
this.shoot(L, L.pxW - 12, L.pxH - yy, -6.5, 0, '#ff4060', true);
|
||||
sfx.shoot();
|
||||
}
|
||||
if (this.stateT > 44) this.state = 'idle';
|
||||
} else {
|
||||
if (this.t % 34 === 0) {
|
||||
const n = 10;
|
||||
const off = this.t / 200;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = off + (i / n) * Math.PI * 2;
|
||||
this.shoot(L, cx, cy, Math.cos(a) * 2.6, Math.sin(a) * 2.6, '#ff5c7a');
|
||||
}
|
||||
sfx.shoot();
|
||||
}
|
||||
if (this.t % 170 === 0) {
|
||||
const a = Math.atan2(player.cy - cy, player.cx - cx);
|
||||
this.shoot(L, cx, cy, Math.cos(a) * 4.4, Math.sin(a) * 4.4, '#ffc63a', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 绘制 ----------------
|
||||
draw(ctx, t) {
|
||||
if (this.dead && this.deathT > 96) return;
|
||||
const x = this.x, y = this.y, w = this.w, h = this.h;
|
||||
const fl = this.flash > 0;
|
||||
ctx.save();
|
||||
if (this.dead) ctx.globalAlpha = Math.max(0, 1 - this.deathT / 96);
|
||||
|
||||
const body = fl ? '#fff' : null;
|
||||
if (this.kind === 'guy') this.drawGuy(ctx, x, y, w, h, body, t);
|
||||
else if (this.kind === 'mecha') this.drawMecha(ctx, x, y, w, h, body, t);
|
||||
else this.drawDevil(ctx, x, y, w, h, body, t);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawGuy(ctx, x, y, w, h, fl, t) {
|
||||
const c = fl || '#2a1020', c2 = fl || '#5a1a34';
|
||||
ctx.fillStyle = c;
|
||||
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.62, w * 0.42, h * 0.34, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = c2;
|
||||
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.3, w * 0.36, h * 0.3, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// 角
|
||||
ctx.fillStyle = fl || '#c8305a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w * 0.2, y + h * 0.16); ctx.lineTo(x + w * 0.05, y - h * 0.12); ctx.lineTo(x + w * 0.34, y + h * 0.06);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w * 0.8, y + h * 0.16); ctx.lineTo(x + w * 0.95, y - h * 0.12); ctx.lineTo(x + w * 0.66, y + h * 0.06);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// 眼
|
||||
ctx.fillStyle = fl ? '#000' : '#ffe14a';
|
||||
ctx.fillRect(x + w * 0.3, y + h * 0.28, w * 0.14, h * 0.1);
|
||||
ctx.fillRect(x + w * 0.56, y + h * 0.28, w * 0.14, h * 0.1);
|
||||
// 嘴
|
||||
ctx.fillStyle = fl ? '#000' : '#801025';
|
||||
ctx.fillRect(x + w * 0.34, y + h * 0.46, w * 0.32, h * 0.06);
|
||||
if (this.phase >= 2) {
|
||||
ctx.strokeStyle = fl ? '#000' : '#ff4060'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w * 0.62 + Math.sin(t / 90) * 3, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawMecha(ctx, x, y, w, h, fl, t) {
|
||||
ctx.fillStyle = fl || '#3a4258';
|
||||
ctx.fillRect(x + 4, y + 6, w - 8, h - 10);
|
||||
ctx.fillStyle = fl || '#68728e';
|
||||
ctx.fillRect(x + 8, y + 10, w - 16, h - 20);
|
||||
// 眼
|
||||
const blink = (Math.floor(t / 200) % 5) === 0;
|
||||
ctx.fillStyle = fl ? '#000' : (this.state === 'dash' ? '#ff4060' : '#7ae0ff');
|
||||
ctx.fillRect(x + w * 0.22, y + h * 0.34, w * 0.18, h * 0.14);
|
||||
ctx.fillRect(x + w * 0.6, y + h * 0.34, w * 0.18, h * 0.14);
|
||||
// 炮口
|
||||
ctx.fillStyle = fl || '#2a2f40';
|
||||
ctx.fillRect(x + w * 0.42, y + h - 8, w * 0.16, 10);
|
||||
// 天线
|
||||
ctx.fillStyle = fl || '#8a94ad';
|
||||
ctx.fillRect(x + w / 2 - 1, y - 6, 2, 8);
|
||||
ctx.fillStyle = (Math.floor(t / 120) % 2) ? '#ff4060' : '#601020';
|
||||
ctx.beginPath(); ctx.arc(x + w / 2, y - 8, 3, 0, Math.PI * 2); ctx.fill();
|
||||
// 推进器
|
||||
if (this.state === 'dash') {
|
||||
ctx.fillStyle = '#ffb040';
|
||||
ctx.fillRect(x - 6, y + h * 0.4, 8, h * 0.2);
|
||||
ctx.fillRect(x + w - 2, y + h * 0.4, 8, h * 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
drawDevil(ctx, x, y, w, h, fl, t) {
|
||||
// 翅膀
|
||||
const fl2 = Math.sin(this.wing) * 8;
|
||||
ctx.fillStyle = fl || '#4a0f22';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 6, y + h * 0.4);
|
||||
ctx.quadraticCurveTo(x - 26, y + h * 0.1 + fl2, x - 4, y + h * 0.72 + fl2);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w - 6, y + h * 0.4);
|
||||
ctx.quadraticCurveTo(x + w + 26, y + h * 0.1 + fl2, x + w + 4, y + h * 0.72 + fl2);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// 身体
|
||||
ctx.fillStyle = fl || '#8e1030';
|
||||
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.6, w * 0.4, h * 0.33, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = fl || '#c01a3a';
|
||||
ctx.beginPath(); ctx.ellipse(x + w / 2, y + h * 0.32, w * 0.34, h * 0.29, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// 角
|
||||
ctx.fillStyle = fl || '#ffc63a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w * 0.18, y + h * 0.14); ctx.lineTo(x - w * 0.06, y - h * 0.16); ctx.lineTo(x + w * 0.32, y + h * 0.04);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w * 0.82, y + h * 0.14); ctx.lineTo(x + w * 1.06, y - h * 0.16); ctx.lineTo(x + w * 0.68, y + h * 0.04);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// 眼(阶段变色)
|
||||
const ec = this.phase === 3 ? '#7ae0ff' : this.phase === 2 ? '#ffb040' : '#ffe14a';
|
||||
ctx.fillStyle = fl ? '#000' : ec;
|
||||
ctx.fillRect(x + w * 0.26, y + h * 0.26, w * 0.16, h * 0.12);
|
||||
ctx.fillRect(x + w * 0.58, y + h * 0.26, w * 0.16, h * 0.12);
|
||||
// 牙
|
||||
ctx.fillStyle = fl ? '#000' : '#fff';
|
||||
for (let i = 0; i < 4; i++) ctx.fillRect(x + w * 0.32 + i * w * 0.1, y + h * 0.46, 4, 6);
|
||||
}
|
||||
}
|
||||
|
||||
export function bossName(kind) { return (DEFS[kind] || DEFS.guy).name; }
|
||||
export { DEFS as BOSS_DEFS };
|
||||
@@ -0,0 +1,90 @@
|
||||
// 摄像机:跟随 / 锁定 / 缩放(坑向关的"摄像头"道具靠它搞事)
|
||||
import { clamp, lerp } from './util.js';
|
||||
|
||||
export class Camera {
|
||||
constructor(w = 800, h = 608) {
|
||||
this.w = w; this.h = h;
|
||||
this.x = 0; this.y = 0;
|
||||
this.zoom = 1;
|
||||
this.tz = 1;
|
||||
this.mode = 'follow';
|
||||
this.lockX = 0; this.lockY = 0;
|
||||
this.shakeX = 0; this.shakeY = 0;
|
||||
this.snapped = false;
|
||||
}
|
||||
|
||||
reset(level, player) {
|
||||
this.zoom = 1; this.tz = 1; this.mode = 'follow';
|
||||
this.snapTo(player, level);
|
||||
}
|
||||
|
||||
viewW() { return this.w / this.zoom; }
|
||||
viewH() { return this.h / this.zoom; }
|
||||
|
||||
snapTo(player, level) {
|
||||
this.x = clamp(player.cx - this.viewW() / 2, 0, Math.max(0, level.pxW - this.viewW()));
|
||||
this.y = clamp(player.cy - this.viewH() / 2, 0, Math.max(0, level.pxH - this.viewH()));
|
||||
this.mode = 'follow';
|
||||
this.zoom = 1; this.tz = 1;
|
||||
}
|
||||
|
||||
update(level, player, dt) {
|
||||
const zone = level.camZone;
|
||||
let tx, ty, tz = 1, speed = 0.16;
|
||||
|
||||
if (zone) {
|
||||
if (zone.mode === 'lock') {
|
||||
this.mode = 'lock';
|
||||
tx = zone.lx !== null ? zone.lx : zone.x;
|
||||
ty = zone.ly !== null ? zone.ly : zone.y;
|
||||
tz = zone.zoom || 1;
|
||||
speed = 0.05; // 缓慢移动,制造迷失感
|
||||
} else if (zone.mode === 'zoom') {
|
||||
this.mode = 'zoom';
|
||||
tz = zone.zoom || 1;
|
||||
tx = player.cx - this.w / tz / 2;
|
||||
ty = player.cy - this.h / tz / 2;
|
||||
speed = 0.14;
|
||||
} else if (zone.mode === 'off') {
|
||||
this.mode = 'off';
|
||||
tx = this.x; ty = this.y; tz = zone.zoom || this.zoom; speed = 0;
|
||||
}
|
||||
} else {
|
||||
this.mode = 'follow';
|
||||
tx = player.cx - this.w / this.zoom / 2;
|
||||
ty = player.cy - this.h / this.zoom / 2;
|
||||
tz = 1;
|
||||
speed = 0.16;
|
||||
}
|
||||
|
||||
this.tz = lerp(this.tz, tz, 0.06);
|
||||
if (Math.abs(this.tz - this.zoom) > 0.002) this.zoom = this.tz;
|
||||
|
||||
if (speed > 0) {
|
||||
this.x = lerp(this.x, tx, speed);
|
||||
this.y = lerp(this.y, ty, speed);
|
||||
}
|
||||
this.clampToLevel(level);
|
||||
|
||||
// 抖动
|
||||
const sh = level.shake || 0;
|
||||
if (sh > 0.2) {
|
||||
this.shakeX = (Math.random() - 0.5) * sh;
|
||||
this.shakeY = (Math.random() - 0.5) * sh;
|
||||
} else { this.shakeX = 0; this.shakeY = 0; }
|
||||
}
|
||||
|
||||
clampToLevel(level) {
|
||||
const vw = this.viewW(), vh = this.viewH();
|
||||
if (level.pxW <= vw) { this.x = (level.pxW - vw) / 2; }
|
||||
else this.x = clamp(this.x, 0, level.pxW - vw);
|
||||
if (level.pxH <= vh) { this.y = (level.pxH - vh) / 2; }
|
||||
else this.y = clamp(this.y, 0, level.pxH - vh);
|
||||
}
|
||||
|
||||
apply(ctx) {
|
||||
ctx.translate(this.shakeX, this.shakeY);
|
||||
if (this.zoom !== 1) ctx.scale(this.zoom, this.zoom);
|
||||
ctx.translate(-this.x, -this.y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
|
||||
import { TILE } from './util.js';
|
||||
import { Save } from './save.js';
|
||||
import { makeCustomLevel } from './levels/index.js';
|
||||
import * as S from './sprites.js';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
|
||||
export const PALETTE = [
|
||||
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||||
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
|
||||
['^', '刺', '#c8c8d8'],
|
||||
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
|
||||
['P', '手枪', '#b8b8c8'], ['F', '苹果', '#ff8095'], ['J', '跳刷新', '#3ddc84'], ['W', '传送门', '#a86bff'],
|
||||
['T', '提示牌', '#c9a86b'], ['O', '炮台', '#6a6a80'], ['D', '掉落刺', '#8a5a6a'], ['C', '摄像头', '#7ae0ff'],
|
||||
['B', 'BOSS', '#ff5c7a'], ['!', '触发器', '#ff9a3a'], ['.', '橡皮', '#20202c'],
|
||||
];
|
||||
|
||||
// 每个方块类型可编辑的属性(属性面板按此渲染)
|
||||
// type: portal(传送门目标) | text | num | select
|
||||
const SCHEMA = {
|
||||
'^': [{ type: 'select', key: 'dir', label: '方向', def: 'up', opts: [['up', '上'], ['down', '下'], ['left', '左'], ['right', '右']] }],
|
||||
W: [{ type: 'portal' }],
|
||||
T: [{ type: 'text', key: 'msg', label: '提示文字', placeholder: '这块牌子显示的话' }],
|
||||
O: [
|
||||
{ type: 'select', key: 'dir', label: '朝向', opts: [['left', '左'], ['right', '右'], ['up', '上'], ['down', '下']] },
|
||||
{ type: 'num', key: 'period', label: '发射间隔(帧)', min: 10, max: 600, def: 90 },
|
||||
{ type: 'num', key: 'speed', label: '子弹速度', min: 0.5, max: 10, step: 0.1, def: 2.4 },
|
||||
{ type: 'num', key: 'offset', label: '初始延迟(帧)', min: 0, max: 600, def: 0 },
|
||||
],
|
||||
C: [
|
||||
{ type: 'num', key: 'w', label: '区域宽(格)', min: 1, max: 40, def: 3 },
|
||||
{ type: 'num', key: 'h', label: '区域高(格)', min: 1, max: 40, def: 3 },
|
||||
{ type: 'select', key: 'mode', label: '镜头模式', opts: [['lock', '锁定'], ['zoom', '缩放'], ['follow-off', '跟随关闭']] },
|
||||
{ type: 'num', key: 'zoom', label: '缩放倍数', min: 0.3, max: 3, step: 0.1, def: 1 },
|
||||
],
|
||||
B: [
|
||||
{ type: 'select', key: 'kind', label: 'BOSS 类型', opts: [['guy', '小兵'], ['mecha', '机甲']] },
|
||||
{ type: 'num', key: 'hp', label: '血量', min: 1, max: 999, def: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
|
||||
|
||||
// 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得
|
||||
function drawTileReal(ctx, ch, px, py, props, t) {
|
||||
const P = props || {};
|
||||
switch (ch) {
|
||||
case '#': S.drawBlock(ctx, px, py, 0); break; // 方块
|
||||
case '@': S.drawBlock(ctx, px, py, 1); break; // 草地
|
||||
case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块
|
||||
case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属
|
||||
case '%': S.drawIce(ctx, px, py); break; // 冰面
|
||||
case '~': S.drawWater(ctx, px, py, t); break; // 水
|
||||
case '-': S.drawOneWay(ctx, px, py); break; // 单向台
|
||||
case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块
|
||||
case '^': { // 刺:真实三角形,按道具方向
|
||||
const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0;
|
||||
S.drawSpike(ctx, px, py, di, t);
|
||||
break;
|
||||
}
|
||||
case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果
|
||||
case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺
|
||||
case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点
|
||||
case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点
|
||||
case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪
|
||||
case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果
|
||||
case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新
|
||||
case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门
|
||||
case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌
|
||||
case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台
|
||||
case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头
|
||||
case 'S': S.drawKid(ctx, { // 出生点:画一个小人
|
||||
x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21,
|
||||
facing: 1, grounded: true, hasGun: false, dead: false,
|
||||
}, t); break;
|
||||
default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底
|
||||
}
|
||||
}
|
||||
|
||||
// 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见
|
||||
function drawMarker(ctx, px, py, ch) {
|
||||
const item = PALETTE.find(p => p[0] === ch);
|
||||
const col = item ? item[2] : '#555';
|
||||
ctx.fillStyle = col; ctx.globalAlpha = 0.85;
|
||||
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
|
||||
}
|
||||
|
||||
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
|
||||
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
|
||||
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
|
||||
|
||||
export const Editor = {
|
||||
api: {},
|
||||
map: null, w: 25, sel: '#',
|
||||
scroll: 0, drag: 0, cur: null,
|
||||
canvas: null, ctx: null,
|
||||
// 多面地图状态
|
||||
faces: null, face: 0,
|
||||
// 当前选中的方块坐标(属性面板依据它渲染)
|
||||
selTile: null,
|
||||
|
||||
init(api) {
|
||||
this.api = api;
|
||||
this.canvas = $('#ed-canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.buildPalette();
|
||||
this.bind();
|
||||
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
$('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
},
|
||||
|
||||
buildPalette() {
|
||||
const p = $('#palette');
|
||||
p.innerHTML = '';
|
||||
PALETTE.forEach(([ch, label]) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'pal' + (ch === this.sel ? ' on' : '');
|
||||
// 用真实绘制函数渲染缩略图,所见即所得
|
||||
const cv = document.createElement('canvas');
|
||||
cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE;
|
||||
drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0);
|
||||
d.appendChild(cv);
|
||||
const sp = document.createElement('span');
|
||||
sp.textContent = label;
|
||||
d.appendChild(sp);
|
||||
d.onclick = () => {
|
||||
this.sel = ch;
|
||||
Array.from(p.children).forEach(c => c.classList.remove('on'));
|
||||
d.classList.add('on');
|
||||
};
|
||||
p.appendChild(d);
|
||||
});
|
||||
},
|
||||
|
||||
bind() {
|
||||
const c = this.canvas;
|
||||
const pos = e => {
|
||||
const r = c.getBoundingClientRect();
|
||||
const sx = (e.clientX - r.left) * (c.width / r.width);
|
||||
const sy = (e.clientY - r.top) * (c.height / r.height);
|
||||
return { tx: Math.floor((sx + this.scroll) / TILE), ty: Math.floor(sy / TILE) };
|
||||
};
|
||||
const act = (e, erase) => {
|
||||
const f = this.curFace();
|
||||
const { tx, ty } = pos(e);
|
||||
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
|
||||
// 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
|
||||
this.selTile = { tx, ty };
|
||||
const prev = f.grid[ty][tx];
|
||||
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
|
||||
// 放置不覆盖:放实方块时若该格已有方块则跳过,仅选中查看属性
|
||||
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
|
||||
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
|
||||
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty);
|
||||
if (ch === 'W' && prev !== 'W') { f.seq = (f.seq || 0) + 1; f.doors.push({ id: f.seq, tx, ty, to: null }); }
|
||||
// 改变方块种类时清空该格属性(属性按类型存储)
|
||||
if (prev !== ch) f.props[ty][tx] = null;
|
||||
f.grid[ty][tx] = ch;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
};
|
||||
c.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
this.drag = e.button === 2 ? 2 : 1;
|
||||
act(e, e.button === 2);
|
||||
});
|
||||
c.addEventListener('mousemove', e => { if (this.drag) act(e, this.drag === 2); });
|
||||
addEventListener('mouseup', () => { this.drag = 0; });
|
||||
c.addEventListener('contextmenu', e => e.preventDefault());
|
||||
c.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
this.scroll = Math.max(0, Math.min((this.curFace().w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
|
||||
this.draw();
|
||||
}, { passive: false });
|
||||
|
||||
const on = (actName, fn) => { const el = document.querySelector(`[data-act="${actName}"]`); if (el) el.onclick = fn; };
|
||||
on('ed-resize', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
on('ed-fill', () => { const f = this.curFace(); for (let x = 0; x < f.w; x++) f.grid[f.h - 1][x] = '#'; this.draw(); });
|
||||
on('ed-clear', () => { if (typeof confirm === 'function' && !confirm('清空当前面?')) return; const f = this.curFace(); f.grid = blank(f.w, f.h); f.doors = []; f.props = propsBlank(f.w, f.h); this.selTile = null; this.draw(); this.renderProps(); });
|
||||
on('ed-test', () => this.test());
|
||||
on('ed-save', () => this.save());
|
||||
on('ed-import', () => this.openImport());
|
||||
on('ed-import-cancel', () => this.closeImport());
|
||||
on('ed-addface', () => this.addFace());
|
||||
on('ed-copyface', () => this.copyFace());
|
||||
on('ed-delface', () => this.delFace());
|
||||
},
|
||||
|
||||
// ---------------- 多面地图状态 ----------------
|
||||
curFace() { return this.faces[this.face]; },
|
||||
|
||||
open() {
|
||||
// 从菜单进入编辑器:始终是新地图(宽高沿用输入框里的值)
|
||||
const w = +$('#ed-w').value || 25, h = +$('#ed-h').value || 19;
|
||||
this.faces = [{ name: '面1', w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) }];
|
||||
this.face = 0;
|
||||
this.cur = null; // 未关联任何已存地图 → 首次保存为新建
|
||||
this.show();
|
||||
},
|
||||
|
||||
/** 仅重绘当前状态(不从零重置):用于试玩后「返回编辑器」等场景,保留正在编辑的地图 */
|
||||
show() {
|
||||
if (!this.faces) { this.open(); return; }
|
||||
this.selTile = null;
|
||||
$('#ed-w').value = this.curFace().w;
|
||||
$('#ed-h').value = this.curFace().h;
|
||||
this.draw();
|
||||
this.renderFaces();
|
||||
this.renderProps();
|
||||
this.updateSaveState();
|
||||
},
|
||||
|
||||
/** 「保存」按钮旁的提示:会更新哪张图,还是另存为新图 */
|
||||
updateSaveState() {
|
||||
const el = $('#ed-savestate');
|
||||
if (!el) return;
|
||||
const l = this.cur ? Save.customs().find(o => o.id === this.cur) : null;
|
||||
el.textContent = l ? '→ 更新「' + (l.name || 'MY MAP') + '」' : '→ 另存为新图';
|
||||
el.classList.toggle('on', !!l);
|
||||
},
|
||||
|
||||
resize(w, h) {
|
||||
w = clamp(w || 25, 12, 160);
|
||||
h = clamp(h || 19, 8, 80);
|
||||
const f = this.curFace();
|
||||
const og = f.grid, op = f.props;
|
||||
f.grid = blank(w, h);
|
||||
f.props = propsBlank(w, h);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
if (og[y] && og[y][x] !== undefined) f.grid[y][x] = og[y][x];
|
||||
if (op[y] && op[y][x] !== undefined) f.props[y][x] = op[y][x];
|
||||
}
|
||||
f.doors = f.doors.filter(d => d.tx < w && d.ty < h);
|
||||
f.w = w; f.h = h;
|
||||
$('#ed-w').value = w; $('#ed-h').value = h;
|
||||
this.canvas.height = h * TILE;
|
||||
this.selTile = null;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
},
|
||||
|
||||
addFace() {
|
||||
const w = this.curFace().w, h = this.curFace().h;
|
||||
this.faces.push({ name: '面' + (this.faces.length + 1), w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) });
|
||||
this.face = this.faces.length - 1;
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
copyFace() {
|
||||
const f = this.curFace();
|
||||
const copy = {
|
||||
name: f.name + ' 副本', w: f.w, h: f.h, seq: f.seq,
|
||||
grid: f.grid.map(r => r.slice()),
|
||||
doors: f.doors.map(d => ({ ...d })),
|
||||
props: f.props.map(r => r.map(c => (c ? { ...c } : null))),
|
||||
};
|
||||
this.faces.push(copy);
|
||||
this.face = this.faces.length - 1;
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
delFace() {
|
||||
if (this.faces.length <= 1) { alert('至少保留一个面'); return; }
|
||||
if (typeof confirm === 'function' && !confirm('删除「' + this.curFace().name + '」?该面的门与连接都会消失')) return;
|
||||
this.faces.splice(this.face, 1);
|
||||
this.face = Math.max(0, this.face - 1);
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
afterFaceSwitch() {
|
||||
$('#ed-w').value = this.curFace().w;
|
||||
$('#ed-h').value = this.curFace().h;
|
||||
this.scroll = 0;
|
||||
this.selTile = null;
|
||||
this.draw();
|
||||
this.renderFaces();
|
||||
this.renderProps();
|
||||
},
|
||||
|
||||
removeDoor(f, tx, ty) {
|
||||
const i = f.doors.findIndex(d => d.tx === tx && d.ty === ty);
|
||||
if (i >= 0) f.doors.splice(i, 1);
|
||||
},
|
||||
|
||||
// ---------------- 绘制 ----------------
|
||||
draw() {
|
||||
const ctx = this.ctx, c = this.canvas;
|
||||
const f = this.curFace();
|
||||
c.width = 800; c.height = f.h * TILE;
|
||||
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.save();
|
||||
ctx.translate(-this.scroll, 0);
|
||||
const t = 0; // 编辑器为静态预览,不跑动画
|
||||
for (let y = 0; y < f.h; y++) {
|
||||
for (let x = 0; x < f.w; x++) {
|
||||
const ch = f.grid[y][x];
|
||||
const px = x * TILE, py = y * TILE;
|
||||
// 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
|
||||
if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
|
||||
// 淡网格线(编辑对位用)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
|
||||
// 传送门:叠加门编号与连接状态(编辑器元数据,游戏内无此显示)
|
||||
if (ch === 'W') {
|
||||
const d = f.doors.find(o => o.tx === x && o.ty === y);
|
||||
ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||
ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 选中方块高亮
|
||||
if (this.selTile && this.selTile.tx >= 0 && this.selTile.ty >= 0 && this.selTile.tx < f.w && this.selTile.ty < f.h) {
|
||||
const sx = this.selTile.tx * TILE, sy = this.selTile.ty * TILE;
|
||||
ctx.strokeStyle = '#ffd23a'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
// ---------------- 面列表面板 ----------------
|
||||
renderFaces() {
|
||||
const box = $('#ed-faces');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
this.faces.forEach((f, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'face-row' + (i === this.face ? ' on' : '');
|
||||
d.innerHTML = `<input class="face-name" value="${escapeHtml(f.name)}" title="面名称"><span class="face-wh">${f.w}×${f.h} · ${f.doors.length}门</span>`;
|
||||
d.querySelector('.face-name').addEventListener('change', e => { f.name = e.target.value; });
|
||||
d.querySelector('.face-name').addEventListener('click', e => e.stopPropagation());
|
||||
d.onclick = () => { this.face = i; this.afterFaceSwitch(); };
|
||||
box.appendChild(d);
|
||||
});
|
||||
},
|
||||
|
||||
// ---------------- 属性面板(选中方块) ----------------
|
||||
renderProps() {
|
||||
const box = $('#ed-props');
|
||||
if (!box) return;
|
||||
const f = this.curFace();
|
||||
if (!this.selTile) { box.innerHTML = '<div class="prop-empty">点击画布上的方块,查看并设置它的属性</div>'; return; }
|
||||
const { tx, ty } = this.selTile;
|
||||
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '<div class="prop-empty">(越界)</div>'; return; }
|
||||
const ch = f.grid[ty][tx];
|
||||
const label = charLabel(ch);
|
||||
const fields = SCHEMA[ch];
|
||||
if (!fields) {
|
||||
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div><div class="prop-empty">该方块没有可设置的属性</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div>`;
|
||||
for (const fd of fields) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'prop-row';
|
||||
if (fd.type === 'portal') {
|
||||
const door = f.doors.find(d => d.tx === tx && d.ty === ty);
|
||||
if (!door) { row.innerHTML = '<span class="prop-lab">跳转到下一关</span><span class="prop-note">(此格未登记为门,请重新放置 W)</span>'; box.appendChild(row); continue; }
|
||||
const cur = door.to || null;
|
||||
const tf = cur ? cur.face : this.face;
|
||||
const td = cur ? cur.door : -1;
|
||||
const faceOpts = this.faces.map((gf, gi) => `<option value="${gi}" ${gi === tf ? 'selected' : ''}>面${gi + 1}${gf.name !== ('面' + (gi + 1)) ? '·' + escapeHtml(gf.name) : ''}</option>`).join('');
|
||||
const tface = this.faces[tf];
|
||||
const doorOpts = (tface.doors.length
|
||||
? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => `<option value="${od.id}" ${td === od.id ? 'selected' : ''}>门${od.id}</option>`).join('')
|
||||
: '') || '<option value="-1">(该面暂无其他门)</option>';
|
||||
row.innerHTML = `<span class="prop-lab">跳转到下一关</span><div class="prop-ctl"><select class="p-face">${faceOpts}</select><select class="p-door">${doorOpts}</select></div>`;
|
||||
const fsel = row.querySelector('.p-face');
|
||||
const dsel = row.querySelector('.p-door');
|
||||
const apply = () => {
|
||||
const ntf = +fsel.value, ntd = +dsel.value;
|
||||
door.to = (ntd >= 0) ? { face: ntf, door: ntd } : null;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
};
|
||||
fsel.onchange = apply;
|
||||
dsel.onchange = apply;
|
||||
} else if (fd.type === 'text') {
|
||||
const val = (f.props[ty][tx] || {})[fd.key] || '';
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="text" placeholder="${fd.placeholder || ''}" value="${escapeHtml(val)}">`;
|
||||
const inp = row.querySelector('.prop-in');
|
||||
inp.oninput = () => { ensureProp(f, tx, ty)[fd.key] = inp.value; };
|
||||
} else if (fd.type === 'num') {
|
||||
const raw = (f.props[ty][tx] || {})[fd.key];
|
||||
const val = raw !== undefined && raw !== null && raw !== '' ? raw : (fd.def !== undefined ? fd.def : '');
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="number" min="${fd.min !== undefined ? fd.min : ''}" max="${fd.max !== undefined ? fd.max : ''}" step="${fd.step !== undefined ? fd.step : 1}" value="${val}">`;
|
||||
const inp = row.querySelector('.prop-in');
|
||||
inp.oninput = () => {
|
||||
const v = inp.value === '' ? (fd.def !== undefined ? fd.def : null) : (fd.step && String(fd.step).includes('.') ? +inp.value : +inp.value);
|
||||
ensureProp(f, tx, ty)[fd.key] = v;
|
||||
};
|
||||
} else if (fd.type === 'select') {
|
||||
const cur = (f.props[ty][tx] || {})[fd.key];
|
||||
const val = cur !== undefined && cur !== '' ? cur : (fd.def !== undefined ? fd.def : '');
|
||||
const opts = fd.opts.map(([v, t]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${t}</option>`).join('');
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><select class="prop-in">${opts}</select>`;
|
||||
const sel = row.querySelector('.prop-in');
|
||||
sel.onchange = () => { ensureProp(f, tx, ty)[fd.key] = sel.value; };
|
||||
}
|
||||
box.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- 数据 ----------------
|
||||
buildData() {
|
||||
const faces = this.faces.map(f => {
|
||||
// 编辑器刺统一为 '^'+props.dir,导出时按方向翻译成引擎字符 ^ v < >
|
||||
const grid = f.grid.map((r, y) => r.map((ch, x) => {
|
||||
if (ch === '^') return SPIKE_CHAR[((f.props[y][x] || {}).dir) || 'up'];
|
||||
return ch;
|
||||
}).join(''));
|
||||
// 按扫描顺序收集各类型属性,整型数组(引擎按扫描下标读取)
|
||||
const messages = [], turrets = [], camzones = [], bosses = [];
|
||||
for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) {
|
||||
const ch = f.grid[y][x];
|
||||
const p = f.props[y][x];
|
||||
if (ch === 'T' && p && p.msg !== undefined && p.msg !== '') messages.push(p.msg);
|
||||
if (ch === 'O' && p) turrets.push({ dir: p.dir || 'left', period: +(p.period || 90), speed: +(p.speed || 2.4), offset: +(p.offset || 0), kind: p.kind || 'ball' });
|
||||
if (ch === 'C' && p) camzones.push({ w: +(p.w || 3), h: +(p.h || 3), mode: p.mode || 'lock', zoom: +(p.zoom || 1) });
|
||||
if (ch === 'B' && p) bosses.push({ kind: p.kind || 'guy', hp: +(p.hp || 40) });
|
||||
}
|
||||
return {
|
||||
name: f.name,
|
||||
width: f.w,
|
||||
height: f.h,
|
||||
grid,
|
||||
doors: f.doors.map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
|
||||
messages, turrets, camzones, bosses,
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: ($('#ed-name').value || 'MY MAP').toUpperCase(),
|
||||
type: 'custom',
|
||||
startFace: 0,
|
||||
faces,
|
||||
};
|
||||
},
|
||||
|
||||
validate() {
|
||||
for (let i = 0; i < this.faces.length; i++) {
|
||||
const g = this.faces[i].grid.map(r => r.join(''));
|
||||
const s = g.join('').split('S').length - 1;
|
||||
const x = g.join('').split('X').length - 1;
|
||||
if (s !== 1) return `面${i + 1}(${this.faces[i].name})必须且只能有 1 个 S,当前 ${s} 个`;
|
||||
if (x < 1) return `面${i + 1}(${this.faces[i].name})至少要 1 个 X(终点)`;
|
||||
}
|
||||
// 门不能指向自己所在格子
|
||||
for (let i = 0; i < this.faces.length; i++) {
|
||||
for (const d of this.faces[i].doors) {
|
||||
if (d.to && d.to.face === i && d.to.door === d.id) return `面${i + 1} 的门${d.id} 不能指向自己`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
test() {
|
||||
const err = this.validate();
|
||||
if (err) { alert(err); return; }
|
||||
this.api.startLevel(makeCustomLevel(this.buildData()));
|
||||
},
|
||||
|
||||
save() {
|
||||
const err = this.validate();
|
||||
if (err) { alert(err); return; }
|
||||
let lv = makeCustomLevel(this.buildData());
|
||||
if (this.cur) lv.id = this.cur; // 保存当前地图:沿用已有 id,就地更新
|
||||
Save.updateCustom(lv);
|
||||
this.cur = lv.id;
|
||||
this.updateSaveState();
|
||||
this.api.refresh?.();
|
||||
},
|
||||
|
||||
// ---------------- 导入地图(从已保存的地图里选) ----------------
|
||||
openImport() {
|
||||
const box = $('#ed-importbox');
|
||||
if (!box) return;
|
||||
const list = $('#ed-import-list');
|
||||
const customs = Save.customs();
|
||||
list.innerHTML = '';
|
||||
if (!customs.length) {
|
||||
list.innerHTML = '<div class="imp-empty">还没有保存过的地图,先「保存」一个吧</div>';
|
||||
} else {
|
||||
customs.forEach(l => {
|
||||
const fn = (l.faces || []).length;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'imp-row' + (l.id === this.cur ? ' on' : '');
|
||||
d.innerHTML = `<span class="imp-nm">${escapeHtml(l.name || 'MY MAP')}</span><span class="imp-meta">${fn ? fn + ' 面' : '单关'}</span>`;
|
||||
d.onclick = () => this.pickImport(l.id);
|
||||
list.appendChild(d);
|
||||
});
|
||||
}
|
||||
box.classList.remove('hidden');
|
||||
},
|
||||
closeImport() { $('#ed-importbox')?.classList.add('hidden'); },
|
||||
pickImport(id) {
|
||||
const l = Save.customs().find(o => o.id === id);
|
||||
if (!l) return;
|
||||
this.cur = l.id;
|
||||
this.hydrate(l);
|
||||
$('#ed-name').value = l.name || '';
|
||||
this.closeImport();
|
||||
this.afterFaceSwitch();
|
||||
this.updateSaveState();
|
||||
},
|
||||
|
||||
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 messages/turrets/camzones/bosses)
|
||||
hydrate(o) {
|
||||
this.faces = o.faces.map(f => {
|
||||
const w = f.width || (f.grid[0] ? f.grid[0].length : 25);
|
||||
const h = f.height || f.grid.length;
|
||||
const grid = f.grid.map(r => r.split(''));
|
||||
const props = propsBlank(w, h);
|
||||
const face = {
|
||||
name: f.name || '面',
|
||||
w, h,
|
||||
seq: (f.doors || []).reduce((m, d) => Math.max(m, d.id), 0),
|
||||
grid,
|
||||
doors: (f.doors || []).map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
|
||||
props,
|
||||
};
|
||||
let mi = 0, ti = 0, ci = 0, bi = 0;
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const ch = grid[y][x];
|
||||
if (ch === 'T') { const m = (f.messages || [])[mi++]; if (m !== undefined) props[y][x] = { msg: m }; }
|
||||
else if (ch === 'O') { const t = (f.turrets || [])[ti++]; if (t) props[y][x] = { ...t }; }
|
||||
else if (ch === 'C') { const c = (f.camzones || [])[ci++]; if (c) props[y][x] = { ...c }; }
|
||||
else if (ch === 'B') { const b = (f.bosses || [])[bi++]; if (b) props[y][x] = { ...b }; }
|
||||
// 引擎的四个刺字符 ^ v < > 统一还原为编辑器 '^' + props.dir
|
||||
else if (SPIKE_DIR[ch] !== undefined) { grid[y][x] = '^'; props[y][x] = { dir: SPIKE_DIR[ch] }; }
|
||||
}
|
||||
return face;
|
||||
});
|
||||
this.face = 0;
|
||||
},
|
||||
};
|
||||
|
||||
function blank(w, h) {
|
||||
return Array.from({ length: h }, () => Array.from({ length: w }, () => '.'));
|
||||
}
|
||||
function propsBlank(w, h) {
|
||||
return Array.from({ length: h }, () => Array.from({ length: w }, () => null));
|
||||
}
|
||||
function ensureProp(f, tx, ty) {
|
||||
if (!f.props[ty][tx]) f.props[ty][tx] = {};
|
||||
return f.props[ty][tx];
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
|
||||
export const BIND = {
|
||||
left: ['ArrowLeft', 'KeyA'],
|
||||
right: ['ArrowRight', 'KeyD'],
|
||||
up: ['ArrowUp', 'KeyW'],
|
||||
down: ['ArrowDown', 'KeyS'],
|
||||
jump: ['ShiftLeft', 'ShiftRight', 'Space', 'KeyZ', 'KeyN'],
|
||||
shoot: ['KeyX', 'KeyJ', 'KeyC', 'KeyM'],
|
||||
suicide: ['KeyR'], // IWBT 标准:自杀并回到最近存档点
|
||||
restart: ['Backspace'], // 整关从头再来(放弃所有存档点)
|
||||
pause: ['Escape', 'KeyP'],
|
||||
menu: ['KeyQ'],
|
||||
};
|
||||
|
||||
// 出厂键位快照(供设置里的「恢复默认」使用)
|
||||
const DEFAULT_BIND = JSON.parse(JSON.stringify(BIND));
|
||||
|
||||
const down = new Set(); // 当前按住的原始 code
|
||||
const now = {}; // action 是否按下
|
||||
const prev = {}; // 上一帧
|
||||
const touch = {}; // action -> bool(来自触屏虚拟按键)
|
||||
const blocked = new Set(['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']);
|
||||
|
||||
export const Input = {
|
||||
enabled: true,
|
||||
/** 是否按下 */
|
||||
is(action) { return !!now[action]; },
|
||||
/** 本帧刚按下 */
|
||||
hit(action) { return !!now[action] && !prev[action]; },
|
||||
/** 本帧刚松开 */
|
||||
released(action) { return !now[action] && !!prev[action]; },
|
||||
raw(code) { return down.has(code); },
|
||||
anyHit() { for (const k in now) if (now[k] && !prev[k]) return true; return false; },
|
||||
|
||||
clear() {
|
||||
down.clear();
|
||||
for (const k in now) now[k] = false;
|
||||
for (const k in prev) prev[k] = false;
|
||||
},
|
||||
|
||||
/** 每逻辑帧末尾调用 */
|
||||
endFrame() {
|
||||
for (const k in now) prev[k] = now[k];
|
||||
},
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
for (const act in BIND) {
|
||||
let v = false;
|
||||
for (const c of BIND[act]) if (down.has(c)) { v = true; break; }
|
||||
if (touch[act]) v = true; // 触屏虚拟按键
|
||||
now[act] = Input.enabled ? v : false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 触屏虚拟按键接口 ----------------
|
||||
export function setTouch(action, on) {
|
||||
if (!(action in touch)) touch[action] = false;
|
||||
if (touch[action] === on) return;
|
||||
touch[action] = on;
|
||||
refresh();
|
||||
}
|
||||
export function clearTouch() {
|
||||
let any = false;
|
||||
for (const k in touch) if (touch[k]) { touch[k] = false; any = true; }
|
||||
if (any) refresh();
|
||||
}
|
||||
|
||||
addEventListener('keydown', e => {
|
||||
if (blocked.has(e.code)) e.preventDefault();
|
||||
if (e.repeat) return;
|
||||
down.add(e.code);
|
||||
refresh();
|
||||
});
|
||||
addEventListener('keyup', e => {
|
||||
down.delete(e.code);
|
||||
refresh();
|
||||
});
|
||||
addEventListener('blur', () => { down.clear(); refresh(); });
|
||||
|
||||
// ---------------- 键位自定义(设置界面调用) ----------------
|
||||
/** 返回一份出厂键位的深拷贝 */
|
||||
export function defaultBindings() { return JSON.parse(JSON.stringify(DEFAULT_BIND)); }
|
||||
/** 用一份 {action: [code,...]} 映射覆盖当前生效键位(设置确定后调用) */
|
||||
export function applyBindings(map) {
|
||||
if (!map) return;
|
||||
for (const act in map) if (act in BIND) BIND[act] = map[act].slice();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
for (const act in BIND) { now[act] = false; prev[act] = false; }
|
||||
export const ACTIONS = Object.keys(BIND);
|
||||
+712
@@ -0,0 +1,712 @@
|
||||
// 关卡解析与运行时:ASCII 网格 -> tiles + 实体 + 事件
|
||||
import { TILE, clamp, overlap, hashNoise } from './util.js';
|
||||
import * as S from './sprites.js';
|
||||
import { sfx } from './audio.js';
|
||||
import { Particles } from './particles.js';
|
||||
import { Boss } from './boss.js';
|
||||
|
||||
export const T = {
|
||||
EMPTY: 0, BLOCK: 1, GRASS: 2, BRICK: 3, METAL: 4,
|
||||
ICE: 5, WATER: 6, ONEWAY: 7, KILLER: 8, VANISH: 9,
|
||||
SPIKE_U: 10, SPIKE_D: 11, SPIKE_L: 12, SPIKE_R: 13,
|
||||
};
|
||||
const SOLID = { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 9: 1 };
|
||||
const DEADLY = { 8: 1, 10: 1, 11: 1, 12: 1, 13: 1 };
|
||||
|
||||
/** ASCII 字符 -> tile */
|
||||
export const CHAR2TILE = {
|
||||
'#': T.BLOCK, '@': T.GRASS, '=': T.BRICK, 'M': T.METAL,
|
||||
'%': T.ICE, '~': T.WATER, '-': T.ONEWAY, '*': T.KILLER, 'V': T.VANISH,
|
||||
'^': T.SPIKE_U, 'v': T.SPIKE_D, '<': T.SPIKE_L, '>': T.SPIKE_R,
|
||||
};
|
||||
/** ASCII 字符 -> 实体类型 */
|
||||
const CHAR2ENT = {
|
||||
S: 'spawn', G: 'save', X: 'goal', P: 'gun', F: 'apple', J: 'jumpref',
|
||||
W: 'warp', T: 'sign', O: 'turret', B: 'boss', D: 'dropspike',
|
||||
C: 'camzone', '!': 'trigger',
|
||||
};
|
||||
const ESIZE = {
|
||||
spawn: [16, 26], save: [20, 22], goal: [30, 42], gun: [28, 24], apple: [18, 20],
|
||||
jumpref: [26, 26], warp: [28, 28], sign: [30, 26], turret: [32, 32],
|
||||
boss: [64, 64], dropspike: [32, 32], camzone: [96, 96], trigger: [32, 32],
|
||||
};
|
||||
|
||||
export class Level {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
this.name = data.name || 'STAGE';
|
||||
this.type = data.type || 'jump';
|
||||
this.parse(data);
|
||||
this.particles = new Particles();
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
// ---------------- 解析 ----------------
|
||||
parse(data) {
|
||||
const rows = data.grid || [];
|
||||
this.h = rows.length || 19;
|
||||
this.w = data.width || Math.max(...rows.map(r => r.length), 25);
|
||||
this.pxW = this.w * TILE;
|
||||
this.pxH = this.h * TILE;
|
||||
this.tiles = [];
|
||||
const srcEntities = [];
|
||||
const order = { warp: 0, boss: 0, sign: 0, turret: 0, camzone: 0, trigger: 0 };
|
||||
|
||||
for (let y = 0; y < this.h; y++) {
|
||||
const line = rows[y] || '';
|
||||
const row = [];
|
||||
for (let x = 0; x < this.w; x++) {
|
||||
const ch = line[x] || ' ';
|
||||
if (CHAR2TILE[ch] !== undefined) {
|
||||
row.push(CHAR2TILE[ch]);
|
||||
} else if (CHAR2ENT[ch] !== undefined) {
|
||||
const type = CHAR2ENT[ch];
|
||||
const [ew, eh] = ESIZE[type];
|
||||
const idx = order[type] !== undefined ? order[type]++ : 0;
|
||||
srcEntities.push({ type, idx, tx: x, ty: y, ch });
|
||||
row.push(T.EMPTY);
|
||||
} else row.push(T.EMPTY);
|
||||
}
|
||||
this.tiles.push(row);
|
||||
}
|
||||
|
||||
// 构建实体
|
||||
this.srcEntities = srcEntities;
|
||||
this.moversSrc = data.movers || [];
|
||||
this.eventSrc = data.events || [];
|
||||
this.msgs = data.messages || [];
|
||||
this.bossSrc = data.bosses || [];
|
||||
this.warpSrc = data.warps || [];
|
||||
this.turretSrc = data.turrets || [];
|
||||
this.camSrc = data.camzones || [];
|
||||
this.spawnExtra = data.spawn || null;
|
||||
this.hint = data.hint || '';
|
||||
this.bgTheme = data.bg || (this.type === 'boss' ? 'boss' : this.type === 'troll' ? 'troll' : this.type === 'endur' ? 'endur' : 'jump');
|
||||
}
|
||||
|
||||
/** 完整重置(死亡 / 重开) */
|
||||
reset(full = false) {
|
||||
// tiles 状态(vanish 等)
|
||||
if (full) { this.deathMarks = []; this.deathFX = []; } // 血迹/死亡特效只在整关重开时清空,复活后保留
|
||||
this.vanish = new Map();
|
||||
this.bullets = [];
|
||||
this.ebullets = [];
|
||||
this.entities = [];
|
||||
this.movers = [];
|
||||
this.bosses = [];
|
||||
this.events = [];
|
||||
this.time = 0;
|
||||
this.toast = null;
|
||||
this.toastT = 0;
|
||||
this.particles?.clear();
|
||||
this.shake = 0;
|
||||
this.beat = false;
|
||||
|
||||
// 实体实例化
|
||||
let saveNo = 0;
|
||||
for (const s of this.srcEntities) {
|
||||
const [ew, eh] = ESIZE[s.type];
|
||||
const e = {
|
||||
type: s.type, idx: s.idx,
|
||||
tx: s.tx, ty: s.ty,
|
||||
x: s.tx * TILE + (TILE - ew) / 2,
|
||||
y: s.ty * TILE + (TILE - eh),
|
||||
w: ew, h: eh,
|
||||
active: false, dead: false, t: 0,
|
||||
};
|
||||
if (s.type === 'goal') e.y = s.ty * TILE + TILE - eh;
|
||||
if (s.type === 'save') { e.fake = false; e.no = ++saveNo; }
|
||||
if (s.type === 'sign') e.text = this.msgs[e.idx] || '...';
|
||||
if (s.type === 'turret') {
|
||||
const cfg = this.turretSrc[e.idx] || {};
|
||||
e.dir = cfg.dir || 'left';
|
||||
e.period = cfg.period || 90;
|
||||
e.speed = cfg.speed || 2.4;
|
||||
e.timer = cfg.offset || 0;
|
||||
e.kind = cfg.kind || 'ball';
|
||||
}
|
||||
if (s.type === 'warp') {
|
||||
const cfg = this.warpSrc[e.idx] || {};
|
||||
// 自制多面地图:门的目标直接挂在 door.to 上
|
||||
// door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送)
|
||||
const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty);
|
||||
e.doorId = door ? door.id : e.idx;
|
||||
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
|
||||
e.to = (door && door.to) ? door.to : (cfg.to || null);
|
||||
e.cool = 0;
|
||||
e.hidden = !!cfg.hidden;
|
||||
}
|
||||
if (s.type === 'camzone') {
|
||||
const cfg = this.camSrc[e.idx] || {};
|
||||
e.w = (cfg.w || 3) * TILE; e.h = (cfg.h || 3) * TILE;
|
||||
e.x = s.tx * TILE; e.y = s.ty * TILE;
|
||||
e.mode = cfg.mode || 'lock'; // lock | zoom | follow-off
|
||||
e.zoom = cfg.zoom || 1;
|
||||
e.lx = cfg.lx !== undefined ? cfg.lx * TILE : null;
|
||||
e.ly = cfg.ly !== undefined ? cfg.ly * TILE : null;
|
||||
}
|
||||
if (s.type === 'dropspike') {
|
||||
e.falling = false; e.vy = 0; e.homeY = e.y;
|
||||
e.triggerX = (this.srcEntities.length ? 0 : 0);
|
||||
}
|
||||
if (s.type === 'boss') {
|
||||
const cfg = this.bossSrc[e.idx] || { kind: 'guy' };
|
||||
e.kind = cfg.kind || 'guy';
|
||||
e.hp = cfg.hp || 40;
|
||||
e.w = cfg.w || 64; e.h = cfg.h || 64;
|
||||
e.x = s.tx * TILE + TILE / 2 - e.w / 2;
|
||||
e.y = s.ty * TILE + TILE - e.h;
|
||||
}
|
||||
this.entities.push(e);
|
||||
}
|
||||
|
||||
// 移动平台
|
||||
for (const m of this.moversSrc) {
|
||||
const path = (m.path || []).map(p => ({ x: p[0] * TILE, y: p[1] * TILE }));
|
||||
const a = path[0] || { x: m.x * TILE, y: m.y * TILE };
|
||||
this.movers.push({
|
||||
x: a.x, y: a.y,
|
||||
w: (m.w || 3) * TILE, h: (m.h || 1) * TILE,
|
||||
path, i: 0, dir: 1, speed: m.speed || 1.2, t: 0,
|
||||
mode: m.mode || 'loop', // loop | pingpong
|
||||
dx: 0, dy: 0,
|
||||
ax: a.x, ay: a.y, aw: (m.w || 3) * TILE, ah: (m.h || 1) * TILE,
|
||||
bx: (path[1] || a).x, by: (path[1] || a).y,
|
||||
});
|
||||
}
|
||||
|
||||
// 事件(坑向触发器)
|
||||
for (const ev of this.eventSrc) {
|
||||
this.events.push({
|
||||
x: ev.x * TILE, y: ev.y * TILE, w: (ev.w || 1) * TILE, h: (ev.h || 1) * TILE,
|
||||
once: ev.once !== false, fired: false,
|
||||
actions: ev.actions || [], hidden: ev.hidden !== false,
|
||||
});
|
||||
}
|
||||
|
||||
// 补齐未在 ASCII 中放置的摄像头区域(camzones 数组独立定义)
|
||||
const camPlaced = this.srcEntities.filter(s => s.type === 'camzone').length;
|
||||
for (let i = camPlaced; i < this.camSrc.length; i++) {
|
||||
const cfg = this.camSrc[i];
|
||||
this.entities.push({
|
||||
type: 'camzone', idx: i, tx: cfg.x, ty: cfg.y,
|
||||
x: cfg.x * TILE, y: cfg.y * TILE,
|
||||
w: (cfg.w || 3) * TILE, h: (cfg.h || 3) * TILE,
|
||||
mode: cfg.mode || 'lock', zoom: cfg.zoom || 1,
|
||||
lx: cfg.lx !== undefined ? cfg.lx * TILE : null,
|
||||
ly: cfg.ly !== undefined ? cfg.ly * TILE : null,
|
||||
active: false, dead: false, t: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// BOSS
|
||||
for (const e of this.entities) {
|
||||
if (e.type !== 'boss') continue;
|
||||
this.bosses.push(new Boss(this, e));
|
||||
}
|
||||
|
||||
// 出生点
|
||||
const sp = this.entities.find(e => e.type === 'spawn');
|
||||
this.spawn = sp
|
||||
? { x: sp.x + sp.w / 2 - 5.5, y: sp.y + sp.h - 21 }
|
||||
: (this.spawnExtra ? { x: this.spawnExtra[0] * TILE, y: this.spawnExtra[1] * TILE } : { x: 40, y: 40 });
|
||||
// 总苹果数 / 存档点数
|
||||
this.totalApples = this.entities.filter(e => e.type === 'apple').length;
|
||||
this.totalSaves = saveNo;
|
||||
}
|
||||
|
||||
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位。
|
||||
* 粒子与震屏是"演出层":不清场——无论主动按 R 还是被动死亡后秒按 R,
|
||||
* 死亡特效都留在死亡位置自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减),
|
||||
* 重生只重置关卡逻辑状态,不掐断演出。整关重开(reset(true))才会清空 */
|
||||
softReset() {
|
||||
this.vanish.clear();
|
||||
if (this.vanishT) this.vanishT.clear();
|
||||
this.bullets.length = 0;
|
||||
this.ebullets.length = 0;
|
||||
for (const m of this.movers) {
|
||||
const a = m.path[0];
|
||||
if (a) { m.x = a.x; m.y = a.y; m.i = 0; }
|
||||
m.dx = m.dy = 0;
|
||||
const nx = m.path[1] || a;
|
||||
m.ax = m.x; m.ay = m.y; m.bx = nx?.x ?? m.x; m.by = nx?.y ?? m.y;
|
||||
}
|
||||
for (const e of this.entities) {
|
||||
if (e.type === 'dropspike') {
|
||||
if (e.temp) { e.dead = true; continue; }
|
||||
e.y = e.homeY; e.falling = false; e.vy = 0; e.landed = false; e.landT = 0;
|
||||
}
|
||||
if (e.type === 'warp') e.cool = 0;
|
||||
}
|
||||
this.entities = this.entities.filter(e => !e.dead);
|
||||
for (const ev of this.events) ev.fired = false;
|
||||
for (const b of this.bosses) {
|
||||
b.hp = b.maxHp; b.dead = false; b.deathT = 0; b.bodyKill = true;
|
||||
b.t = 0; b.state = 'idle'; b.stateT = 0; b.phase = 1;
|
||||
b.x = b.homeX; b.y = b.homeY; b.vx = 0; b.vy = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 死亡标记:在死亡地点留下一个常驻「红叉」(IWBTG 风格死亡指示)。
|
||||
// 只保留上一次死亡的红叉:新增前先清空,多次死亡不会累积。
|
||||
addDeathMark(x, y) {
|
||||
this.deathMarks.length = 0;
|
||||
this.deathMarks.push({ x, y });
|
||||
}
|
||||
|
||||
// 死亡特效(扩散冲击环):记录到关卡层而非玩家——主动重生时玩家当帧就复活了,
|
||||
// 环要留在死亡位置播完。t 由 update 推进,14 帧扩散淡出后移除
|
||||
addDeathFX(x, y) {
|
||||
this.deathFX.push({ x, y, t: 0 });
|
||||
}
|
||||
|
||||
drawDeathRing(ctx, fx) {
|
||||
const t = fx.t;
|
||||
if (t > 14) return;
|
||||
const r = 4 + t * 1.8, a = Math.max(0, 1 - t / 14);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a * 0.85;
|
||||
ctx.strokeStyle = '#ff8fa3';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx.x, fx.y, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = a * 0.3;
|
||||
ctx.fillStyle = '#ff5c7a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx.x, fx.y, r * 0.55, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawDeathMark(ctx, dm) {
|
||||
const s = 4; // 红叉半边长(< kid 的 11×21,整体约 8px)
|
||||
ctx.lineCap = 'round';
|
||||
// 深色描边,保证在任意背景下都清晰
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
|
||||
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
|
||||
ctx.stroke();
|
||||
// 红色主体
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#ff2535';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dm.x - s, dm.y - s); ctx.lineTo(dm.x + s, dm.y + s);
|
||||
ctx.moveTo(dm.x + s, dm.y - s); ctx.lineTo(dm.x - s, dm.y + s);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ---------------- 查询 ----------------
|
||||
tileAt(tx, ty) {
|
||||
if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return ty < 0 ? T.EMPTY : T.BLOCK;
|
||||
return this.tiles[ty][tx];
|
||||
}
|
||||
pxTile(px, py) { return this.tileAt(Math.floor(px / TILE), Math.floor(py / TILE)); }
|
||||
|
||||
isSolidTile(t, tx, ty) {
|
||||
if (!SOLID[t]) return false;
|
||||
if (t === T.VANISH) {
|
||||
const st = this.vanishState(tx, ty);
|
||||
return st !== 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
vanishState(tx, ty) { return this.vanish.get(ty * 1000 + tx) ?? 0; }
|
||||
setVanish(tx, ty, st) { this.vanish.set(ty * 1000 + tx, st); }
|
||||
|
||||
/** 矩形是否与实心地形相交(可指定忽略单向平台的下落判定) */
|
||||
solidRect(x, y, w, h, opts = {}) {
|
||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||||
for (let ty = y0; ty <= y1; ty++) {
|
||||
for (let tx = x0; tx <= x1; tx++) {
|
||||
const t = this.tileAt(tx, ty);
|
||||
if (t === T.ONEWAY) {
|
||||
if (!opts.dropThrough && opts.prevBottom !== undefined && opts.prevBottom <= ty * TILE + 6) {
|
||||
// 从上方落下:只有当前底部穿过平台面时才碰撞
|
||||
if (y + h <= ty * TILE + 8) continue;
|
||||
if (opts.prevBottom > ty * TILE + 2) continue;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (this.isSolidTile(t, tx, ty)) return true;
|
||||
}
|
||||
}
|
||||
// 移动平台
|
||||
for (const m of this.movers) {
|
||||
if (m.gone) continue;
|
||||
if (x < m.x + m.w && x + w > m.x && y < m.y + m.h && y + h > m.y) {
|
||||
if (opts.moverExclude === m) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 致死判定(刺 / 美味水果 / 敌人子弹) */
|
||||
deadlyRect(x, y, w, h) {
|
||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||||
for (let ty = y0; ty <= y1; ty++) {
|
||||
for (let tx = x0; tx <= x1; tx++) {
|
||||
const t = this.tileAt(tx, ty);
|
||||
if (!DEADLY[t]) continue;
|
||||
const tr = spikeTri(t, tx * TILE, ty * TILE);
|
||||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||||
}
|
||||
}
|
||||
for (const e of this.entities) {
|
||||
if (e.type === 'dropspike' && e.falling) {
|
||||
const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
|
||||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||||
}
|
||||
}
|
||||
for (const b of this.ebullets) {
|
||||
if (b.dead) continue;
|
||||
const r = { x: b.x, y: b.y, w: b.w, h: b.h };
|
||||
if (overlap({ x, y, w, h }, r)) return true;
|
||||
}
|
||||
for (const bs of this.bosses) {
|
||||
if (bs.dead || !bs.bodyKill) continue;
|
||||
if (overlap({ x, y, w, h }, bs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
waterRect(x, y, w, h) {
|
||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||||
for (let ty = y0; ty <= y1; ty++)
|
||||
for (let tx = x0; tx <= x1; tx++)
|
||||
if (this.tileAt(tx, ty) === T.WATER) return true;
|
||||
return false;
|
||||
}
|
||||
iceAtBottom(x, y, w, h) {
|
||||
const tx0 = Math.floor(x / TILE), tx1 = Math.floor((x + w - 1) / TILE);
|
||||
const ty = Math.floor((y + h + 1) / TILE);
|
||||
for (let tx = tx0; tx <= tx1; tx++) if (this.tileAt(tx, ty) === T.ICE) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------- 更新 ----------------
|
||||
update(dt, player, cam) {
|
||||
this.time += dt;
|
||||
const t = this.time;
|
||||
|
||||
// 死亡特效(冲击环)推进:14 帧后移除;粒子由 particles.update 自理
|
||||
for (let i = this.deathFX.length - 1; i >= 0; i--) {
|
||||
if (++this.deathFX[i].t > 14) this.deathFX.splice(i, 1);
|
||||
}
|
||||
|
||||
// 移动平台
|
||||
for (const m of this.movers) {
|
||||
if (!m.path.length) continue;
|
||||
const tgt = m.path[(m.i + 1) % m.path.length];
|
||||
const ox = m.x, oy = m.y;
|
||||
const dx = tgt.x - m.x, dy = tgt.y - m.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist <= m.speed) {
|
||||
m.x = tgt.x; m.y = tgt.y;
|
||||
m.i = (m.i + 1) % m.path.length;
|
||||
} else {
|
||||
m.x += dx / dist * m.speed;
|
||||
m.y += dy / dist * m.speed;
|
||||
}
|
||||
m.dx = m.x - ox; m.dy = m.y - oy;
|
||||
const next = m.path[(m.i + 1) % m.path.length];
|
||||
m.ax = m.x; m.ay = m.y; m.bx = next.x; m.by = next.y;
|
||||
}
|
||||
|
||||
// 消失方块计时
|
||||
for (const [key, st] of this.vanish) {
|
||||
if (st === 0) continue;
|
||||
const tt = (this.vanishT?.get(key) || 0) + 1;
|
||||
this.vanishT = this.vanishT || new Map();
|
||||
this.vanishT.set(key, tt);
|
||||
if (st === 1 && tt > 55) { this.vanish.set(key, 2); this.vanishT.set(key, 0); }
|
||||
else if (st === 2 && tt > 110) { this.vanish.set(key, 0); this.vanishT.set(key, 0); }
|
||||
}
|
||||
|
||||
// 掉落刺
|
||||
for (const e of this.entities) {
|
||||
if (e.type !== 'dropspike') continue;
|
||||
if (!e.falling) {
|
||||
const px = player.x + player.w / 2;
|
||||
if (Math.abs(px - (e.x + e.w / 2)) < 26 && Math.abs(player.y - e.y) < 260 && player.y > e.y) {
|
||||
e.falling = true; e.vy = 0;
|
||||
sfx.vanish();
|
||||
}
|
||||
} else if (e.landed) {
|
||||
// 落地后插在地上,80 帧后碎掉(否则会永久堵死落脚点)
|
||||
e.landT = (e.landT || 0) + 1;
|
||||
if (e.landT > 80) { e.dead = true; e.falling = false; }
|
||||
} else {
|
||||
e.vy = Math.min(e.vy + 0.5, 11);
|
||||
e.y += e.vy;
|
||||
const ty = Math.floor((e.y + e.h) / TILE);
|
||||
const tx = Math.floor((e.x + e.w / 2) / TILE);
|
||||
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
|
||||
e.y = ty * TILE - e.h; e.vy = 0; e.landed = true; e.landT = 0;
|
||||
this.shake = Math.max(this.shake, 5);
|
||||
}
|
||||
if (e.y > this.pxH + 100) e.dead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 炮台
|
||||
for (const e of this.entities) {
|
||||
if (e.type !== 'turret') continue;
|
||||
e.t++;
|
||||
if (e.t % (e.period || 90) === 0) {
|
||||
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||||
let vx = 0, vy = 0;
|
||||
if (e.dir === 'left') vx = -e.speed;
|
||||
else if (e.dir === 'right') vx = e.speed;
|
||||
else if (e.dir === 'up') vy = -e.speed;
|
||||
else vy = e.speed;
|
||||
if (e.kind === 'aim' && player.alive) {
|
||||
const dx = (player.x + player.w / 2) - cx, dy = (player.y + player.h / 2) - cy;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
vx = dx / d * e.speed; vy = dy / d * e.speed;
|
||||
}
|
||||
this.ebullets.push({ x: cx - 4, y: cy - 4, w: 8, h: 8, vx, vy, dead: false, col: '#ff5c7a', life: 400 });
|
||||
sfx.shoot();
|
||||
}
|
||||
}
|
||||
|
||||
// 敌人子弹
|
||||
for (const b of this.ebullets) {
|
||||
b.x += b.vx; b.y += b.vy; b.life--;
|
||||
if (b.life <= 0) b.dead = true;
|
||||
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
|
||||
if (this.isSolidTile(this.tileAt(tx, ty), tx, ty)) {
|
||||
b.dead = true;
|
||||
this.particles.spawn(b.x + 4, b.y + 4, 4, { colors: ['#ff5c7a', '#fff'], speed: 2, life: 14 });
|
||||
}
|
||||
}
|
||||
this.ebullets = this.ebullets.filter(b => !b.dead);
|
||||
|
||||
// 玩家子弹
|
||||
for (const b of this.bullets) {
|
||||
b.x += b.vx; b.y += b.vy; b.life--;
|
||||
if (b.life <= 0) b.dead = true;
|
||||
const tx = Math.floor((b.x + b.w / 2) / TILE), ty = Math.floor((b.y + b.h / 2) / TILE);
|
||||
const tt = this.tileAt(tx, ty);
|
||||
if (tt === T.BLOCK || tt === T.GRASS || tt === T.BRICK || tt === T.METAL) {
|
||||
b.dead = true;
|
||||
this.particles.spawn(b.x + 3, b.y + 3, 5, { colors: ['#fff6a0', '#ffb040'], speed: 2.4, life: 16 });
|
||||
}
|
||||
}
|
||||
this.bullets = this.bullets.filter(b => !b.dead);
|
||||
|
||||
// 事件触发
|
||||
for (const ev of this.events) {
|
||||
if (ev.fired && ev.once) continue;
|
||||
if (overlap(player, ev)) this.fireEvent(ev, player);
|
||||
}
|
||||
|
||||
// 摄像机区域
|
||||
this.camZone = null;
|
||||
for (const e of this.entities) {
|
||||
if (e.type !== 'camzone') continue;
|
||||
if (overlap({ x: player.x + player.w / 2 - 1, y: player.y + player.h / 2 - 1, w: 2, h: 2 }, e)) {
|
||||
this.camZone = e; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shake > 0) this.shake *= 0.86;
|
||||
if (this.toastT > 0 && --this.toastT === 0) this.toast = null;
|
||||
this.particles.update();
|
||||
|
||||
// 死亡红叉为常驻静态标记,无需逐帧演化,复活后仍保留
|
||||
}
|
||||
|
||||
fireEvent(ev, player) {
|
||||
ev.fired = true;
|
||||
for (const a of ev.actions) {
|
||||
if (a.type === 'msg') { this.showToast(a.text, a.dur || 150); }
|
||||
else if (a.type === 'spikes') {
|
||||
// 生成一排下落刺
|
||||
for (let i = 0; i < (a.count || 5); i++) {
|
||||
const x = (a.x + i * (a.gap || 1)) * TILE;
|
||||
this.entities.push({
|
||||
type: 'dropspike', x, y: (a.y || 0) * TILE, w: TILE, h: TILE,
|
||||
falling: true, vy: 0, homeY: (a.y || 0) * TILE, t: 0, temp: true,
|
||||
});
|
||||
}
|
||||
sfx.troll();
|
||||
} else if (a.type === 'vanish') {
|
||||
for (let i = 0; i < (a.count || 3); i++) {
|
||||
const tx = (a.x + i), ty = a.y;
|
||||
if (this.tileAt(tx, ty) === T.EMPTY) continue;
|
||||
this.setVanish(tx, ty, 1);
|
||||
this.vanishT = this.vanishT || new Map();
|
||||
this.vanishT.set(ty * 1000 + tx, 0);
|
||||
}
|
||||
sfx.vanish();
|
||||
} else if (a.type === 'warp') {
|
||||
player.x = a.x * TILE; player.y = a.y * TILE;
|
||||
player.vx = 0; player.vy = 0;
|
||||
sfx.warp();
|
||||
} else if (a.type === 'kill') { player.kill(); }
|
||||
else if (a.type === 'shake') { this.shake = a.v || 10; }
|
||||
}
|
||||
}
|
||||
|
||||
showToast(text, dur = 150) { this.toast = text; this.toastT = dur; }
|
||||
|
||||
// ---------------- 绘制 ----------------
|
||||
drawBg(ctx, cam, t) {
|
||||
const g = ctx.createLinearGradient(0, 0, 0, cam.h);
|
||||
const th = {
|
||||
jump: ['#1a1830', '#0a0a14'],
|
||||
endur: ['#101f24', '#080c10'],
|
||||
boss: ['#2a1018', '#0d0810'],
|
||||
troll: ['#221432', '#0b0812'],
|
||||
custom: ['#141428', '#08080f'],
|
||||
}[this.bgTheme] || ['#1a1830', '#0a0a14'];
|
||||
g.addColorStop(0, th[0]); g.addColorStop(1, th[1]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, cam.w, cam.h);
|
||||
|
||||
// 远景装饰(视差)
|
||||
const px = -cam.x * 0.25, py = -cam.y * 0.15;
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const nx = hashNoise(i, 7) * 2000, ny = hashNoise(i, 13) * 900;
|
||||
const sx = ((nx + px) % (cam.w + 200)) - 100;
|
||||
const sy = ((ny + py) % (cam.h + 120)) - 60;
|
||||
ctx.fillRect(sx, sy, 2, 2);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// 网格
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.035)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
const off = TILE * 2;
|
||||
const sx0 = -((cam.x % off));
|
||||
const sy0 = -((cam.y % off));
|
||||
for (let x = sx0; x < cam.w; x += off) { ctx.moveTo(x + .5, 0); ctx.lineTo(x + .5, cam.h); }
|
||||
for (let y = sy0; y < cam.h; y += off) { ctx.moveTo(0, y + .5); ctx.lineTo(cam.w, y + .5); }
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
draw(ctx, cam, t) {
|
||||
const vx0 = Math.max(0, Math.floor(cam.x / TILE) - 1);
|
||||
const vx1 = Math.min(this.w - 1, Math.ceil((cam.x + cam.w / (cam.zoom || 1)) / TILE) + 1);
|
||||
const vy0 = Math.max(0, Math.floor(cam.y / TILE) - 1);
|
||||
const vy1 = Math.min(this.h - 1, Math.ceil((cam.y + cam.h / (cam.zoom || 1)) / TILE) + 1);
|
||||
|
||||
ctx.save();
|
||||
// 摄像机变换由外部处理
|
||||
for (let ty = vy0; ty <= vy1; ty++) {
|
||||
for (let tx = vx0; tx <= vx1; tx++) {
|
||||
const v = this.tiles[ty][tx];
|
||||
if (v === T.EMPTY) continue;
|
||||
const x = tx * TILE, y = ty * TILE;
|
||||
switch (v) {
|
||||
case T.BLOCK: S.drawBlock(ctx, x, y, 0); break;
|
||||
case T.GRASS: S.drawBlock(ctx, x, y, 1); break;
|
||||
case T.BRICK: S.drawBlock(ctx, x, y, 2); break;
|
||||
case T.METAL: S.drawBlock(ctx, x, y, 3); break;
|
||||
case T.ICE: S.drawIce(ctx, x, y); break;
|
||||
case T.WATER: S.drawWater(ctx, x, y, t); break;
|
||||
case T.ONEWAY: S.drawOneWay(ctx, x, y); break;
|
||||
case T.KILLER: S.drawKiller(ctx, x, y, t); break;
|
||||
case T.VANISH: S.drawVanish(ctx, x, y, this.vanishState(tx, ty), t); break;
|
||||
case T.SPIKE_U: S.drawSpike(ctx, x, y, 0, t); break;
|
||||
case T.SPIKE_D: S.drawSpike(ctx, x, y, 1, t); break;
|
||||
case T.SPIKE_L: S.drawSpike(ctx, x, y, 2, t); break;
|
||||
case T.SPIKE_R: S.drawSpike(ctx, x, y, 3, t); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动平台
|
||||
for (const m of this.movers) S.drawMovingPlatform(ctx, m);
|
||||
|
||||
// 实体
|
||||
for (const e of this.entities) {
|
||||
if (e.dead) continue;
|
||||
switch (e.type) {
|
||||
case 'save': S.drawSave(ctx, e, t); break;
|
||||
case 'goal': S.drawGoal(ctx, e, t); break;
|
||||
case 'gun': if (!e.taken) S.drawGun(ctx, e, t); break;
|
||||
case 'apple': if (!e.taken) S.drawApple(ctx, e, t); break;
|
||||
case 'jumpref': S.drawJumpRef(ctx, e, t); break;
|
||||
case 'warp': if (!e.hidden) S.drawWarp(ctx, e, t); break;
|
||||
case 'sign': S.drawSign(ctx, e, t); break;
|
||||
case 'turret': S.drawTurret(ctx, e, t); break;
|
||||
case 'camzone': S.drawCamera(ctx, e, t); break;
|
||||
case 'dropspike': S.drawDropSpike(ctx, e, t); break;
|
||||
}
|
||||
}
|
||||
|
||||
// 子弹
|
||||
for (const b of this.bullets) S.drawBullet(ctx, b);
|
||||
for (const b of this.ebullets) S.drawEBullet(ctx, b, t);
|
||||
|
||||
// 死亡血迹(IWBTG 风格,常驻于死亡地点)
|
||||
for (const dm of this.deathMarks) this.drawDeathMark(ctx, dm);
|
||||
|
||||
// 死亡特效(扩散冲击环,14 帧淡出)
|
||||
for (const fx of this.deathFX) this.drawDeathRing(ctx, fx);
|
||||
|
||||
this.particles.draw(ctx);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** 屏幕空间 UI:提示牌文字 / toast */
|
||||
drawOverlay(ctx, cam, t) {
|
||||
for (const e of this.entities) {
|
||||
if (e.type !== 'sign') continue;
|
||||
const sx = (e.x + e.w / 2 - cam.x) * (cam.zoom || 1);
|
||||
const sy = (e.y - 6 - cam.y) * (cam.zoom || 1);
|
||||
if (sx < -80 || sx > cam.w + 80) continue;
|
||||
ctx.font = 'bold 11px "Courier New",monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const txt = e.text;
|
||||
const wd = ctx.measureText(txt).width;
|
||||
ctx.fillStyle = 'rgba(0,0,0,.72)';
|
||||
ctx.fillRect(sx - wd / 2 - 5, sy - 13, wd + 10, 16);
|
||||
ctx.fillStyle = '#ffe9a8';
|
||||
ctx.fillText(txt, sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
|
||||
// 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
|
||||
function spikeTri(t, x, y) {
|
||||
const s = TILE;
|
||||
if (t === T.SPIKE_U) return { ax: x + s / 2, ay: y, bx: x, by: y + s, cx: x + s, cy: y + s };
|
||||
if (t === T.SPIKE_D) return { ax: x + s / 2, ay: y + s, bx: x, by: y, cx: x + s, cy: y };
|
||||
if (t === T.SPIKE_L) return { ax: x, ay: y + s / 2, bx: x + s, by: y, cx: x + s, cy: y + s };
|
||||
if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
|
||||
return { ax: x, ay: y, bx: x + s, by: y, cx: x, cy: y + s }; // 兜底(不应到达)
|
||||
}
|
||||
|
||||
// 三角形与轴对齐矩形的相交判定(分离轴定理,凸多边形充要条件)。
|
||||
function triRectOverlap(ax, ay, bx, by, cx, cy, rx, ry, rw, rh) {
|
||||
const rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh;
|
||||
const tMinX = Math.min(ax, bx, cx), tMaxX = Math.max(ax, bx, cx);
|
||||
const tMinY = Math.min(ay, by, cy), tMaxY = Math.max(ay, by, cy);
|
||||
if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; // AABB 快速排除
|
||||
const edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]];
|
||||
for (const [ex, ey] of edges) {
|
||||
const nx = -ey, ny = ex; // 边法线
|
||||
const t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy;
|
||||
const tMin = Math.min(t0, t1, t2), tMax = Math.max(t0, t1, t2);
|
||||
const p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY,
|
||||
p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY;
|
||||
const rMin = Math.min(p0, p1, p2, p3), rMax = Math.max(p0, p1, p2, p3);
|
||||
if (tMax < rMin || rMax < tMin) return false; // 找到分离轴
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export { spikeTri, triRectOverlap };
|
||||
@@ -0,0 +1,63 @@
|
||||
// BOSS 战关卡(击败 BOSS 即通关)
|
||||
import { G, rep, E, plat, ent } from './kit.js';
|
||||
|
||||
export default [
|
||||
{
|
||||
id: 'b1', name: 'THE GUY', type: 'boss', diff: 3, bg: 'boss', width: 48,
|
||||
hint: 'X 键射击。他放扇形弹+砸地冲击波,站在平台边缘躲开再输出。',
|
||||
startGun: true,
|
||||
bosses: [{ kind: 'guy', hp: 72 }],
|
||||
grid: G(48, [
|
||||
rep('#', 48), // 0
|
||||
E(48), E(48), E(48), E(48), E(48), E(48), E(48),// 1-7
|
||||
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 8
|
||||
E(48), E(48), E(48), // 9-11
|
||||
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 12
|
||||
E(48), E(48), E(48), // 13-15
|
||||
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 16
|
||||
E(48), E(48), E(48), // 17-19
|
||||
ent(48, [2, 'S'], [24, 'B']), // 20
|
||||
rep('#', 48), // 21
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'b2', name: 'MECHA KID', type: 'boss', diff: 4, bg: 'boss', width: 48,
|
||||
hint: '冲刺时贴地必死。跳上高台躲,等他停下再打。',
|
||||
startGun: true,
|
||||
bosses: [{ kind: 'mecha', hp: 96 }],
|
||||
grid: G(48, [
|
||||
rep('#', 48), // 0
|
||||
E(48), E(48), E(48), E(48), // 1-4
|
||||
'#' + rep('.', 12) + '######' + rep('.', 10) + '######', // 5
|
||||
E(48), E(48), E(48), // 6-8
|
||||
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 9
|
||||
E(48), E(48), E(48), // 10-12
|
||||
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 13
|
||||
E(48), E(48), E(48), // 14-16
|
||||
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 17
|
||||
E(48), E(48), E(48), // 18-19
|
||||
ent(48, [2, 'S'], [24, 'B']), // 20
|
||||
rep('#', 48), // 21
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'b3', name: 'THE DEVIL', type: 'boss', diff: 5, bg: 'boss', width: 48,
|
||||
hint: '三阶段:追踪弹 → 激光横扫 → 环形弹幕。地上还有刺。活下来。',
|
||||
startGun: true,
|
||||
bosses: [{ kind: 'devil', hp: 128 }],
|
||||
grid: G(48, [
|
||||
rep('#', 48), // 0
|
||||
E(48), E(48), E(48), // 1-3
|
||||
'#' + rep('.', 5) + '######' + rep('.', 24) + '######', // 4
|
||||
E(48), E(48), E(48), // 5-7
|
||||
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 8
|
||||
E(48), E(48), E(48), // 9-11
|
||||
'#' + rep('.', 3) + '######' + rep('.', 28) + '######', // 12
|
||||
E(48), E(48), E(48), // 13-15
|
||||
'#' + rep('.', 15) + '######' + rep('.', 8) + '######', // 16
|
||||
E(48), E(48), E(48), // 17-19
|
||||
ent(48, [2, 'S'], [24, 'B'], [12, '^'], [13, '^'], [34, '^'], [35, '^']), // 20
|
||||
rep('#', 48), // 21
|
||||
]),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,131 @@
|
||||
// 耐久(Endurance)关卡:长距离连续考验,存档点稀疏
|
||||
import { G, rep, E, plat, ent, flr } from './kit.js';
|
||||
|
||||
export default [
|
||||
// ---------------- 1 ----------------
|
||||
{
|
||||
id: 'e1', name: 'LONG ROAD', type: 'endur', diff: 3, bg: 'endur', width: 100,
|
||||
hint: '100 格长路,8 个刺坑,只有 5 个存档点。别急。',
|
||||
messages: ['才刚刚开始'],
|
||||
grid: G(100, [
|
||||
rep('#', 100), // 0
|
||||
E(100), E(100), E(100), E(100), E(100), // 1-5
|
||||
E(100), E(100), E(100), E(100), E(100), // 6-10
|
||||
E(100), // 11
|
||||
ent(100, [15, 'v'], [17, 'v'], [30, 'v'], [32, 'v'], [55, 'v'], [57, 'v'], [75, 'v'], [77, 'v']), // 12
|
||||
ent(100, [43, 'F'], [90, 'F']), // 13
|
||||
'#' + rep('.', 40) + '######' + rep('.', 41) + '######', // 14
|
||||
E(100), E(100), // 15-16
|
||||
ent(100, [2, 'S'], [16, 'G'], [30, 'G'], [52, 'G'], [76, 'G'], [98, 'X'], // 17
|
||||
[26, '^'], [27, '^'], [40, '^'], [41, '^'], [63, '^'], [64, '^'], [78, '^'], [79, '^']),
|
||||
flr(['#', 9], ['^', 3], ['#', 7], ['^', 4], ['#', 11], ['^', 3], ['#', 6], ['^', 5],
|
||||
['#', 9], ['^', 3], ['#', 8], ['^', 4], ['#', 10], ['^', 3], ['#', 6], ['^', 5], ['#', 3]), // 18
|
||||
]),
|
||||
},
|
||||
|
||||
// ---------------- 2 ----------------
|
||||
{
|
||||
id: 'e2', name: 'TURRET HELL', type: 'endur', diff: 4, bg: 'endur', width: 88,
|
||||
hint: '炮台沿路扫射。踩空就是刺海,别站在开阔地。',
|
||||
turrets: [
|
||||
{ dir: 'right', period: 78, speed: 2.8, offset: 0 },
|
||||
{ dir: 'left', period: 92, speed: 2.6, offset: 25 },
|
||||
{ dir: 'right', period: 70, speed: 3.0, offset: 10 },
|
||||
{ dir: 'left', period: 86, speed: 2.7, offset: 40 },
|
||||
{ dir: 'right', period: 74, speed: 2.9, offset: 20 },
|
||||
{ dir: 'aim', period: 110, speed: 2.4, offset: 55 },
|
||||
],
|
||||
grid: G(88, [
|
||||
rep('#', 88), // 0
|
||||
E(88), E(88), E(88), E(88), E(88), E(88), // 1-6
|
||||
E(88), E(88), E(88), // 7-9
|
||||
E(88), // 10
|
||||
ent(88, [83, 'X']), // 11
|
||||
plat(88, 82, 5), // 12
|
||||
E(88), // 13
|
||||
plat(88, 78, 5), // 14
|
||||
E(88), // 15
|
||||
ent(88, [83, 'G']), // 16
|
||||
plat(88, 82, 5), // 17
|
||||
E(88), // 18
|
||||
plat(88, 78, 5), // 19
|
||||
E(88), // 20
|
||||
ent(88, [2, 'S'], [4, 'O'], [10, 'G'], [20, 'O'], [30, 'G'], [36, 'O'],
|
||||
[50, 'G'], [52, 'O'], [68, 'O'], [70, 'G'], [84, 'O']), // 21
|
||||
'#' + '.' + rep('#####...', 10) + '#####', // 22
|
||||
'#' + rep('^', 87), // 23
|
||||
rep('#', 88), // 24
|
||||
]),
|
||||
},
|
||||
|
||||
// ---------------- 3 ----------------
|
||||
{
|
||||
id: 'e3', name: 'THE PIT', type: 'endur', diff: 4, bg: 'endur', width: 64,
|
||||
hint: '先从左侧竖井下到底,游过水下刺池,再从右边一路爬回顶端。',
|
||||
messages: ['下面有东西在等你'],
|
||||
grid: G(64, [
|
||||
rep('#', 64), // 0
|
||||
E(64), E(64), // 1-2
|
||||
ent(64, [57, 'X']), // 3
|
||||
'#' + '..' + 'S' + rep('.', 52) + rep('#', 7), // 4 出生 + 右·顶台
|
||||
'#.' + rep('#', 7), // 5 左
|
||||
'#' + rep('.', 50) + 'G', // 6
|
||||
'#' + rep('.', 48) + rep('#', 7), // 7 右
|
||||
'#' + rep('.', 10) + rep('#', 7), // 8 左
|
||||
'#' + rep('.', 58) + 'G', // 9
|
||||
'#' + '...' + 'G' + rep('.', 51) + rep('#', 7), // 10 右
|
||||
'#.' + rep('#', 7), // 11 左
|
||||
'#' + rep('.', 50) + 'G', // 12
|
||||
'#' + rep('.', 48) + rep('#', 7), // 13 右
|
||||
'#' + rep('.', 10) + rep('#', 7), // 14 左
|
||||
E(64), // 15
|
||||
'#' + '...' + 'G' + rep('.', 51) + rep('#', 7), // 16 右
|
||||
'#.' + rep('#', 7), // 17 左
|
||||
E(64), // 18
|
||||
'#' + rep('.', 48) + rep('#', 7), // 19 右
|
||||
'#' + rep('.', 10) + rep('#', 7), // 20 左
|
||||
E(64), // 21
|
||||
'#' + rep('.', 12) + 'G' + rep('.', 42) + rep('#', 7), // 22 右
|
||||
'#' + rep('.', 10) + rep('#', 7), // 23 左·井底台
|
||||
'#' + rep('.', 50) + 'G', // 24 出水台
|
||||
'#' + rep('.', 17) + rep('~', 31) + rep('#', 7) + rep('.', 8), // 25
|
||||
'#' + rep('.', 17) + rep('~', 31) + rep('.', 14), // 26
|
||||
'#' + rep('.', 17) + rep('~', 31) + rep('.', 14), // 27
|
||||
'#' + rep('^', 63), // 28
|
||||
rep('#', 64), // 29
|
||||
]),
|
||||
},
|
||||
|
||||
// ---------------- 4 ----------------
|
||||
{
|
||||
id: 'e4', name: 'MARATHON', type: 'endur', diff: 5, bg: 'endur', width: 120,
|
||||
hint: '120 格:冰面、炮台、刺坑、移动平台全都有。5 个存档,自求多福。',
|
||||
turrets: [
|
||||
{ dir: 'right', period: 76, speed: 2.9, offset: 0 },
|
||||
{ dir: 'left', period: 88, speed: 2.7, offset: 30 },
|
||||
{ dir: 'right', period: 68, speed: 3.1, offset: 15 },
|
||||
{ dir: 'aim', period: 120, speed: 2.3, offset: 50 },
|
||||
{ dir: 'left', period: 80, speed: 2.8, offset: 25 },
|
||||
],
|
||||
grid: G(120, [
|
||||
rep('#', 120), // 0
|
||||
E(120), E(120), E(120), E(120), E(120), // 1-5
|
||||
E(120), E(120), E(120), E(120), E(120), // 6-10
|
||||
E(120), E(120), E(120), E(120), E(120), // 11-15
|
||||
E(120), // 16
|
||||
ent(120, [20, 'v'], [22, 'v'], [36, 'v'], [38, 'v'], [52, 'v'], [54, 'v'],
|
||||
[70, 'v'], [72, 'v'], [88, 'v'], [90, 'v'], [104, 'v'], [106, 'v']), // 17
|
||||
E(120), E(120), E(120), E(120), // 18-21
|
||||
ent(120, [2, 'S'], [20, 'O'], [24, 'G'], [36, 'O'], [40, 'G'], [52, 'O'],
|
||||
[66, 'G'], [70, 'O'], [88, 'G'], [100, 'O'], [104, 'G'], [116, 'X'],
|
||||
[17, '^'], [18, '^'], [54, '^'], [55, '^'], [72, '^'], [73, '^'], [105, '^'], [106, '^']), // 22
|
||||
flr(['%', 10], ['^', 4], ['#', 12], ['^', 5], ['%', 12], ['^', 4],
|
||||
['#', 10], ['^', 5], ['#', 15], ['^', 4], ['%', 10], ['^', 5],
|
||||
['#', 12], ['^', 4], ['#', 7]), // 23
|
||||
rep('#', 120), // 24
|
||||
]),
|
||||
movers: [
|
||||
{ x: 57, y: 22, w: 3, h: 1, path: [[57, 22], [63, 22]], speed: 1.3 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
import tutorial from './tutorial.js';
|
||||
import jump from './jump.js';
|
||||
import endurance from './endurance.js';
|
||||
import boss from './boss.js';
|
||||
import troll from './troll.js';
|
||||
|
||||
const raw = [...tutorial, ...jump, ...endurance, ...boss, ...troll];
|
||||
raw.forEach((l, i) => { l.index = i; l.builtin = true; });
|
||||
|
||||
export const LEVELS = raw;
|
||||
|
||||
export const TYPE_LABEL = {
|
||||
jump: '跳刺', endur: '耐久', boss: 'BOSS', troll: '坑向', custom: '自制',
|
||||
};
|
||||
export const TYPE_CLASS = {
|
||||
jump: 't-jump', endur: 't-endur', boss: 't-boss', troll: 't-troll', custom: 't-custom',
|
||||
};
|
||||
|
||||
let uid = 0;
|
||||
export function makeCustomLevel(data) {
|
||||
return { ...data, id: 'u' + (Date.now() % 100000) + (uid++), type: 'custom', builtin: false, index: -1 };
|
||||
}
|
||||
|
||||
export function findLevel(id) {
|
||||
return LEVELS.find(l => l.id === id);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
|
||||
// 设计语法(已经 tools/solve.mjs 验证):
|
||||
// 刺毯缺口每 3 格一个(跳 2 连刺 = 64px 单跳节奏);钻石中心 = 地板上方 2~3 格;
|
||||
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
|
||||
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
|
||||
import { Grid } from './kit.js';
|
||||
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
|
||||
|
||||
// 刺毯:从 x0 起 n 格留落脚缺口。
|
||||
// 缺口间距统一 3 格(跳 2 连刺 = 64px)。
|
||||
// 2026-10-23:JUMP 7.5 + GRAV_FALL 0.55 下单跳水平实测 118.8px(tools/jumpdist.mjs),
|
||||
// 但落地前一帧竖直速度已达 8.9px/帧,3 连刺(96px)的起拍窗被压到 0~1 帧(不可玩);
|
||||
// 2 连刺留 55px 射程余量,落地窗恢复充裕。井口/左端右行出口处的 3 连刺须显式拆断(见各关 gaps 注释)
|
||||
const carpet = (m, x0, y, n, gaps = []) => {
|
||||
const g = [];
|
||||
let i = 3;
|
||||
while (i < n) { g.push(i); i += 3; }
|
||||
return spikeRun(m, x0, y, n, '^', g.concat(gaps));
|
||||
};
|
||||
|
||||
export const JUMP_LEVELS = [
|
||||
// ============================================================ j1 刺巷
|
||||
{
|
||||
id: 'j1', name: 'SPIKE ALLEY', cn: '刺巷', type: 'jump', diff: 2,
|
||||
hint: '针刺入门:刺毯单跳节奏、钻石缝、矮洞撞头跳、竖井、对刺门。',
|
||||
width: 104,
|
||||
grid: (() => {
|
||||
const W = 104, H = 26, R = 22;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '#'); // 主地板
|
||||
// --- x1-9 起步 ---
|
||||
m.set(3, R - 1, 'S');
|
||||
m.set(8, R - 1, 'G');
|
||||
// --- x10-26 刺毯(单跳节奏,垂刺压顶禁二段,两道上下夹缝)---
|
||||
carpet(m, 10, R - 1, 17);
|
||||
ceil(m, 10, 26, R - 6);
|
||||
vgap(m, 15, R - 2); vgap(m, 21, R - 2);
|
||||
// --- x28-43 钻石门三连(中心 R-2,单跳穿缝)---
|
||||
carpet(m, 28, R - 1, 16, [0, 4, 8, 12, 15]); // 加密缺口便于站位
|
||||
ceil(m, 28, 43, R - 6);
|
||||
vgap(m, 30, R - 2); vgap(m, 34, R - 2); vgap(m, 38, R - 2);
|
||||
m.set(28, R - 1, 'G');
|
||||
// --- x44-56 矮洞(顶 row 18,3 格高,撞头跳 + 对刺门)---
|
||||
m.rect(44, R - 4, 13, 1, '#');
|
||||
carpet(m, 45, R - 1, 11);
|
||||
hgap(m, 48, R - 1); hgap(m, 53, R - 1);
|
||||
m.set(45, R - 1, 'G');
|
||||
// --- x57-71 刺坑上的钢丝柱 ---
|
||||
for (let x = 57; x <= 70; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
const wires = [[58, 20], [61, 19], [64, 20], [67, 18], [70, 20]];
|
||||
for (const [x, top] of wires) m.vline(x, top, R - 1, '#');
|
||||
m.set(72, R - 1, 'G');
|
||||
// --- x74-79 竖井上上层(井底留 3 格入口)---
|
||||
m.vline(74, R - 10, R - 4, '#');
|
||||
m.vline(79, R - 12, R - 1, '#');
|
||||
block(m, 75, R - 3, 2); // 19
|
||||
block(m, 77, R - 6, 2); // 16
|
||||
block(m, 75, R - 9, 2); // 13
|
||||
wall(m, 79, R - 5, R - 5, '<'); wall(m, 74, R - 7, R - 7, '>');
|
||||
// --- 上层回廊(地板 row 12)x80-97 ---
|
||||
m.rect(80, R - 10, 18, 1, '#');
|
||||
carpet(m, 81, R - 11, 15, [0, 8]); // 8=x89:给对刺门留左侧起拍位(新物理下跳台落点偏右,须能走进 x90)
|
||||
ceil(m, 81, 95, R - 16); // row 6 垂刺
|
||||
hgap(m, 92, R - 11); // 对刺门
|
||||
m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G');
|
||||
// --- x98-102 下落井 + 终点 ---
|
||||
wall(m, 99, R - 7, R - 3, '>');
|
||||
wall(m, 102, R - 5, R - 2, '<');
|
||||
m.set(100, R - 1, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j2 垂直地狱
|
||||
{
|
||||
id: 'j2', name: 'VERTICAL HELL', cn: '垂直地狱', type: 'jump', diff: 3,
|
||||
hint: '52 格高塔:四段之字竖井,每层刺毯+上下夹缝,垂刺压顶、掉落刺追身。',
|
||||
width: 68,
|
||||
grid: (() => {
|
||||
const W = 68, H = 52;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
|
||||
m.rect(1, 48, 66, 1, '#');
|
||||
m.set(3, 47, 'S');
|
||||
carpet(m, 5, 47, 50, [1, 18]); // 1=x6 出生旁缺口;18=x23 放宽落点窗(3 连刺落点贴右腰会被 6px 状态桶卡死)
|
||||
ceil(m, 5, 54, 43);
|
||||
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
|
||||
m.set(11, 47, 'G'); m.set(39, 47, 'G');
|
||||
// ---- 右井 x58..65:L0 → L1(井底 3 格敞口,之字每级升 2 格)----
|
||||
well(m, 58, 65, 40, 48, 'l');
|
||||
m.set(65, 45, '^'); m.set(58, 43, '^'); // 踏石外沿刺,逼迫精准落点
|
||||
// ---- L1 地板 row 40(向左;垂刺避开左井 x2..9)----
|
||||
m.rect(2, 40, 56, 1, '#');
|
||||
carpet(m, 10, 39, 44);
|
||||
ceil(m, 10, 54, 35);
|
||||
vgap(m, 42, 38); vgap(m, 26, 38); vgap(m, 12, 38);
|
||||
m.set(47, 39, 'G'); m.set(19, 39, 'G');
|
||||
// ---- 左井 x2..8:L1 → L2 ----
|
||||
well(m, 2, 8, 32, 40, 'r');
|
||||
m.set(2, 37, '^'); m.set(8, 35, '^');
|
||||
// ---- L2 地板 row 32(向右,井底延伸到右井下方)----
|
||||
m.rect(9, 32, 57, 1, '#');
|
||||
carpet(m, 11, 31, 46, [1, 11, 31]); // 1=x12 拆井口 3 连刺;11=x22/31=x42 是 hgap 夹缝通道列(必须为空,否则整门变 3 连刺、4 格跳超射程)
|
||||
ceil(m, 11, 54, 27);
|
||||
hgap(m, 22, 31); hgap(m, 42, 31);
|
||||
vgap(m, 32, 30); vgap(m, 50, 30);
|
||||
m.set(19, 31, 'G'); m.set(47, 31, 'G');
|
||||
// ---- 右井 x58..65:L2 → L3 ----
|
||||
well(m, 58, 65, 24, 32, 'l');
|
||||
m.set(65, 29, '^'); m.set(58, 27, '^');
|
||||
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
|
||||
m.rect(2, 24, 56, 1, '#');
|
||||
carpet(m, 10, 23, 44, [0]); // 0=x10:拆掉左端 3 连刺(左行跳落点差 0.8px 蹭 x10 左坡;L1 同位置能过是因 vgap(12) 清空了 x10-14,L3 没有)
|
||||
ceil(m, 10, 54, 19);
|
||||
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
|
||||
vgap(m, 40, 22); vgap(m, 22, 22);
|
||||
m.set(47, 23, 'G'); m.set(19, 23, 'G');
|
||||
// ---- 左井 x2..8:L3 → L4 ----
|
||||
well(m, 2, 8, 16, 24, 'r');
|
||||
m.set(2, 21, '^'); m.set(8, 19, '^');
|
||||
// ---- L4 地板 row 16(向右到终点)----
|
||||
m.rect(9, 16, 57, 1, '#');
|
||||
carpet(m, 11, 15, 52, [1, 41]); // 1=x12 拆井口 3 连刺;41=x52 是 hgap 夹缝通道列(必须为空)
|
||||
ceil(m, 11, 60, 11);
|
||||
hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15);
|
||||
[18, 34, 56].forEach(x => m.set(x, 10, 'D'));
|
||||
m.set(27, 15, 'G'); m.set(51, 15, 'G');
|
||||
m.set(62, 15, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j3 钢丝
|
||||
{
|
||||
id: 'j3', name: 'TIGHTROPE', cn: '钢丝', type: 'jump', diff: 3,
|
||||
hint: '一格宽的柱子立在刺海上,高矮间距都不同,垂刺压着头顶。',
|
||||
width: 132,
|
||||
grid: (() => {
|
||||
const W = 132, H = 24, R = 20;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '#'); // 基座
|
||||
spikeRun(m, 4, R - 1, W - 6, '^'); // 刺海
|
||||
// 钢丝柱(顶可站 1 格),高矮错落、间距 3~5
|
||||
const cols = [
|
||||
[4, 18], [8, 17], [12, 19], [15, 17], [19, 18], [23, 16], [27, 19],
|
||||
[30, 17], [34, 18], [38, 16], [42, 19], [46, 17], [50, 15], [54, 18],
|
||||
[58, 17], [62, 19], [66, 16], [70, 18], [74, 17], [78, 19], [82, 15],
|
||||
[86, 18], [90, 17], [94, 19], [98, 16], [102, 18], [106, 17], [110, 19],
|
||||
[114, 16], [118, 18], [122, 17],
|
||||
];
|
||||
for (const [x, top] of cols) m.vline(x, top, R - 1, '#');
|
||||
// 起点/终点平台
|
||||
m.rect(1, R - 1, 3, 1, '#'); m.set(2, R - 2, 'S');
|
||||
m.rect(126, R - 1, 4, 1, '#'); m.set(128, R - 2, 'X');
|
||||
// 垂刺段(离最低柱顶 5 格:单跳安全,二段跳死)
|
||||
ceil(m, 6, 28, 11); // 段内最低顶 16
|
||||
ceil(m, 32, 56, 10); // 最低顶 15
|
||||
ceil(m, 60, 88, 10);
|
||||
ceil(m, 92, 120, 11);
|
||||
// 存档(柱顶)
|
||||
m.set(23, 15, 'G'); m.set(58, 16, 'G'); m.set(90, 16, 'G'); m.set(122, 16, 'G');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j4 冰刺
|
||||
{
|
||||
id: 'j4', name: 'ICE NEEDLES', cn: '冰刺', type: 'jump', diff: 4,
|
||||
hint: '冰面刹不住车:滑坑、冰上钻石、冰柱钢丝,最后还有一段冰顶矮洞。',
|
||||
width: 132,
|
||||
grid: (() => {
|
||||
const W = 132, H = 26, R = 22;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '%'); // 冰地板
|
||||
m.set(3, R - 1, 'S');
|
||||
// --- x6-30 冰面刺坑(3 格宽:新物理单跳 108.8px > 96px;旧 4~5 格坑跳不过)---
|
||||
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [25, 27]])
|
||||
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
|
||||
m.set(12, R - 1, 'G');
|
||||
// --- x32-52 冰台阶 + 角刺 + 钻石 ---
|
||||
block(m, 33, R - 2, 2, '%');
|
||||
block(m, 38, R - 2, 2);
|
||||
block(m, 43, R - 4, 2, '%');
|
||||
ceil(m, 31, 52, R - 5);
|
||||
vgap(m, 48, R - 2);
|
||||
m.set(41, R - 1, 'G');
|
||||
// --- x54-78 冰柱钢丝 ---
|
||||
spikeRun(m, 54, R - 1, 25, '^');
|
||||
const ice = [[56, 19], [60, 18], [64, 20], [68, 17], [72, 19], [76, 18]];
|
||||
for (const [x, top] of ice) m.vline(x, top, R - 1, '%');
|
||||
ceil(m, 55, 77, 12); // 最低柱顶 17 → 12
|
||||
m.set(64, 19, 'G');
|
||||
// --- x80-100 冰顶矮洞(2 格高,撞头跳越刺)---
|
||||
m.rect(80, R - 3, 21, 1, '%'); // 冰顶 row 19
|
||||
spikeRun(m, 83, R - 1, 15, '^', [0, 4, 8, 12, 14]);
|
||||
m.set(81, R - 1, 'G'); m.set(99, R - 1, 'G');
|
||||
// --- x102-124 冰上钻石阵 ---
|
||||
carpet(m, 102, R - 1, 23, '%'.length ? [] : []); // 冰面刺毯
|
||||
ceil(m, 102, 124, R - 5);
|
||||
vgap(m, 106, R - 2); vgap(m, 111, R - 2);
|
||||
vgap(m, 116, R - 2); vgap(m, 121, R - 2);
|
||||
m.set(103, R - 1, 'G');
|
||||
m.set(126, R - 1, 'G');
|
||||
m.set(129, R - 1, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j5 关口
|
||||
{
|
||||
id: 'j5', name: 'THE GAUNTLET', cn: '关口', type: 'jump', diff: 4,
|
||||
hint: '移动平台跨刺海,炮台点名,矮洞与钻石阵穿插。',
|
||||
width: 122,
|
||||
grid: (() => {
|
||||
const W = 122, H = 28, R = 24;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '#');
|
||||
m.set(3, R - 1, 'S');
|
||||
// --- x5-22 热身刺毯(三道上下夹缝)---
|
||||
carpet(m, 5, R - 1, 17);
|
||||
ceil(m, 5, 21, R - 6);
|
||||
vgap(m, 9, R - 2); vgap(m, 15, R - 2); vgap(m, 21, R - 2);
|
||||
m.set(7, R - 1, 'G');
|
||||
// --- x23-49 移动平台跨海 ---
|
||||
for (let x = 23; x <= 49; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
m.set(50, R - 1, 'G');
|
||||
// --- x51-72 矮洞 + 炮台 ---
|
||||
m.rect(51, R - 4, 22, 1, '#'); // 顶 row 20
|
||||
carpet(m, 52, R - 1, 20, [0]);
|
||||
hgap(m, 57, R - 1); hgap(m, 64, R - 1); hgap(m, 70, R - 1);
|
||||
m.set(56, R - 5, 'O');
|
||||
m.set(52, R - 1, 'G');
|
||||
// --- x73-97 浮块跨刺海 + 交叉火力 ---
|
||||
spikeRun(m, 73, R - 1, 24, '^');
|
||||
block(m, 76, R - 2, 2); block(m, 82, R - 4, 2); block(m, 88, R - 2, 2); block(m, 93, R - 3, 2);
|
||||
ceil(m, 75, 96, R - 9); // 浮块区压顶:高块上二段跳必死
|
||||
m.set(79, R - 6, 'O'); m.set(91, R - 6, 'O');
|
||||
m.set(95, R - 1, 'G');
|
||||
// --- x98-118 钻石收尾 ---
|
||||
carpet(m, 98, R - 1, 20, [0, 1, 19]);
|
||||
ceil(m, 98, 117, R - 6);
|
||||
vgap(m, 101, R - 2); vgap(m, 106, R - 2); vgap(m, 111, R - 2); vgap(m, 116, R - 2);
|
||||
m.set(98, R - 1, 'G');
|
||||
m.set(119, R - 1, 'G');
|
||||
m.set(120, R - 1, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
movers: [
|
||||
{ x: 25, y: 22, w: 2, h: 1, path: [[25, 22], [35, 22]], speed: 1.6 },
|
||||
{ x: 39, y: 20, w: 2, h: 1, path: [[39, 20], [47, 20]], speed: 1.9 },
|
||||
],
|
||||
turrets: [
|
||||
{ dir: 'left', period: 70, speed: 2.8, offset: 0 },
|
||||
{ dir: 'left', period: 62, speed: 3.0, offset: 24 },
|
||||
{ dir: 'left', period: 66, speed: 2.7, offset: 40 },
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================ j6 针刺教堂
|
||||
{
|
||||
id: 'j6', name: 'NEEDLE CATHEDRAL', cn: '针刺教堂', type: 'jump', diff: 5,
|
||||
hint: '三层殿堂:底层向右,竖井爬升,中层折返向左,再爬升,顶层冲刺。',
|
||||
width: 132,
|
||||
grid: (() => {
|
||||
const W = 132, H = 36;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
// ============ L0 底层(地板 row 30)============
|
||||
m.rect(1, 30, W - 2, 1, '#');
|
||||
m.set(3, 29, 'S');
|
||||
carpet(m, 5, 29, 116);
|
||||
ceil(m, 5, 120, 24);
|
||||
vgap(m, 18, 28); vgap(m, 46, 28); vgap(m, 74, 28); vgap(m, 102, 28);
|
||||
block(m, 32, 29, 2); block(m, 60, 29, 2);
|
||||
block(m, 88, 29, 2); block(m, 112, 29, 2);
|
||||
m.set(11, 29, 'G'); m.set(55, 29, 'G'); m.set(95, 29, 'G');
|
||||
// ============ 右井 x122-129 → L1(左墙底部留 3 格入口)============
|
||||
m.vline(122, 17, 26, '#'); m.vline(129, 16, 29, '#');
|
||||
block(m, 124, 27, 2); block(m, 126, 24, 2); block(m, 124, 21, 2); block(m, 126, 18, 2);
|
||||
wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
|
||||
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
|
||||
m.rect(1, 16, 122, 1, '#');
|
||||
carpet(m, 9, 15, 112, [86]); // 86=x95:补 hgap(95) 通道列(否则门芯留一根 '^',GRAV_FALL 0.40 下回跳轨迹下降慢正好嵌进去)
|
||||
ceil(m, 9, 118, 10);
|
||||
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
|
||||
hgap(m, 95, 15); hgap(m, 43, 15);
|
||||
m.set(44, 15, '.'); // 拆掉对刺门左刺'<':GRAV 0.33 下从 x45 起跳第 3 帧必被刺尖别住(起拍窗窄到 2px),清掉后留 ^+> 双刺门,整格起拍都能过
|
||||
block(m, 69, 15, 2); block(m, 17, 15, 2);
|
||||
m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
|
||||
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============
|
||||
m.vline(8, 9, 12, '#');
|
||||
block(m, 4, 13, 2); block(m, 2, 10, 2); block(m, 4, 7, 2);
|
||||
block(m, 6, 7, 2); // 桥
|
||||
wall(m, 8, 11, 11, '<'); wall(m, 2, 10, 10, '>');
|
||||
// ============ L2 顶层(地板 row 8,向右到终点)============
|
||||
m.rect(8, 8, 118, 1, '#');
|
||||
carpet(m, 9, 7, 114);
|
||||
m.set(8, 7, '.'); m.set(9, 7, '.'); m.set(10, 7, '.'); m.set(11, 7, '.'); // 井口净空
|
||||
ceil(m, 9, 122, 2);
|
||||
vgap(m, 24, 6); vgap(m, 60, 6); vgap(m, 96, 6);
|
||||
hgap(m, 36, 7); hgap(m, 84, 7);
|
||||
m.set(15, 7, 'G'); m.set(51, 7, 'G'); m.set(87, 7, 'G');
|
||||
m.set(122, 7, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j7 天降刺
|
||||
{
|
||||
id: 'j7', name: 'SKY SPIKES', cn: '天降刺', type: 'jump', diff: 4,
|
||||
hint: '之字爬塔,每层头顶悬着掉落刺。走近就掉。别停。',
|
||||
width: 72,
|
||||
grid: (() => {
|
||||
const W = 72, H = 52;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, 48, W - 2, 1, '#');
|
||||
m.set(3, 47, 'S');
|
||||
// 6 层之字平台:每层刺毯 + 钻石 + 掉落刺
|
||||
const tiers = [
|
||||
{ y: 44, x0: 4, x1: 30 }, { y: 38, x0: 34, x1: 62 },
|
||||
{ y: 32, x0: 6, x1: 34 }, { y: 26, x0: 38, x1: 64 },
|
||||
{ y: 20, x0: 8, x1: 36 }, { y: 14, x0: 40, x1: 64 },
|
||||
];
|
||||
for (const t of tiers) {
|
||||
m.rect(t.x0, t.y, t.x1 - t.x0 + 1, 1, '#');
|
||||
// 3/4 格交替刺毯;两端各留 2 格净空作为上一层跃入 / 下一层起跳的落脚点
|
||||
const n = t.x1 - t.x0 + 1;
|
||||
carpet(m, t.x0, t.y - 1, n, [0, 1, n - 2, n - 1]);
|
||||
for (let x = t.x0 + 5; x <= t.x1 - 3; x += 7) m.set(x, t.y - 8, 'D');
|
||||
ceil(m, t.x0 + 2, t.x1 - 2, t.y - 5); // 压顶垂刺:层内二段跳必死
|
||||
vgap(m, Math.floor((t.x0 + t.x1) / 2), t.y - 2);
|
||||
}
|
||||
// 层间连接块(升 2 格,落点对准缺口)
|
||||
block(m, 32, 46, 2);
|
||||
block(m, 32, 42, 2); block(m, 34, 40, 2);
|
||||
block(m, 32, 36, 2); block(m, 30, 34, 2);
|
||||
block(m, 36, 30, 2); block(m, 38, 28, 2);
|
||||
block(m, 36, 24, 2); block(m, 34, 22, 2);
|
||||
block(m, 38, 18, 2); block(m, 40, 16, 2);
|
||||
// 存档(各层缺口上)
|
||||
m.set(7, 43, 'G'); m.set(37, 37, 'G'); m.set(9, 31, 'G');
|
||||
m.set(41, 25, 'G'); m.set(11, 19, 'G'); m.set(43, 13, 'G');
|
||||
m.set(60, 13, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j8 三重威胁
|
||||
{
|
||||
id: 'j8', name: 'TRIPLE THREAT', cn: '三重威胁', type: 'jump', diff: 4,
|
||||
hint: '冰面滑行、塌陷浮桥、单向台攀爬三连考,中间不许喘气。',
|
||||
width: 130,
|
||||
grid: (() => {
|
||||
const W = 130, H = 26, R = 22;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '#');
|
||||
m.set(3, R - 1, 'S');
|
||||
// --- x1-34 冰面段 ---
|
||||
m.rect(1, R, 36, 1, '%');
|
||||
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [26, 29]])
|
||||
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
vgap(m, 11, R - 2); vgap(m, 17, R - 2); vgap(m, 24, R - 2); vgap(m, 30, R - 2);
|
||||
m.set(11, R - 1, 'G'); m.set(32, R - 1, 'G');
|
||||
// --- x38-74 塌陷浮桥(桥下刺海)---
|
||||
for (let x = 38; x <= 74; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
for (let x = 38; x <= 74; x++) m.set(x, R - 3, 'V');
|
||||
m.set(46, R - 4, '^'); m.set(50, R - 4, '^'); m.set(54, R - 4, '^');
|
||||
m.set(58, R - 4, '^'); m.set(62, R - 4, '^'); m.set(66, R - 4, '^'); m.set(70, R - 4, '^');
|
||||
ceil(m, 40, 72, R - 9); // 垂刺限高
|
||||
m.set(51, R - 4, 'G');
|
||||
// --- x76-100 单向台攀爬 ---
|
||||
for (let x = 76; x <= 99; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
m.set(78, R - 2, '-'); m.set(79, R - 2, '-');
|
||||
m.set(83, R - 4, '-'); m.set(84, R - 4, '-');
|
||||
m.set(87, R - 6, '-'); m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
|
||||
m.set(93, R - 4, '-'); m.set(94, R - 4, '-');
|
||||
m.set(97, R - 2, '-'); m.set(98, R - 2, '-');
|
||||
// x86 只留压顶刺:JUMP 8.0 下 apex 盒底 479 与 '^' 顶点 480 只差 1px,
|
||||
// 上升航线有 4 帧顶点落在盒内必死(8.5 时 apex 466 可从容擦过),故去掉地刺
|
||||
m.set(86, R - 9, 'v');
|
||||
vgap(m, 93, R - 6);
|
||||
m.set(101, R - 1, 'G');
|
||||
// --- x102-126 钻石收尾 ---
|
||||
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
|
||||
ceil(m, 102, 125, R - 5);
|
||||
vgap(m, 105, R - 2); vgap(m, 110, R - 2);
|
||||
vgap(m, 115, R - 2); vgap(m, 120, R - 2);
|
||||
m.set(102, R - 1, 'G');
|
||||
m.set(126, R - 1, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j9 樱花
|
||||
{
|
||||
id: 'j9', name: 'CHERRY BLOSSOM', cn: '樱花', type: 'jump', diff: 5,
|
||||
hint: '双层结构收 3 个苹果:钻石缝中央一个,矮洞里一个,中层对刺门后一个。',
|
||||
width: 128,
|
||||
grid: (() => {
|
||||
const W = 128, H = 34;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
// ============ L0 下层(地板 row 28)============
|
||||
m.rect(1, 28, W - 2, 1, '#');
|
||||
m.set(3, 27, 'S');
|
||||
carpet(m, 5, 27, 116, [54]); // 54=x59:拆开矮洞入口的四格跳(洞顶 row24 会挡跳的横向漂移)
|
||||
ceil(m, 5, 120, 22);
|
||||
vgap(m, 14, 26); vgap(m, 38, 26);
|
||||
vgap(m, 86, 26); vgap(m, 110, 26);
|
||||
block(m, 26, 27, 2); block(m, 50, 27, 2);
|
||||
block(m, 74, 27, 2); block(m, 98, 27, 2);
|
||||
m.set(11, 27, 'G'); m.set(55, 27, 'G'); m.set(95, 27, 'G');
|
||||
// 井口前净空(否则刺毯把竖井入口封死)
|
||||
m.set(117, 27, '.'); m.set(118, 27, '.'); m.set(119, 27, '.'); m.set(120, 27, '.');
|
||||
// 苹果 1:钻石缝中央
|
||||
m.set(62, 26, 'F');
|
||||
// ============ L0 中段矮洞(顶 row 24,3 格高;撞头跳射程≈3 格,洞内缺口加密到 3)============
|
||||
m.rect(60, 24, 16, 1, '#');
|
||||
[63, 66, 69, 72, 75].forEach(x => m.set(x, 27, '.'));
|
||||
// 苹果 2:矮洞里(跳跃途中拾取)
|
||||
m.set(66, 26, 'F');
|
||||
// ============ 右井 x120-126 → L1(左墙底部留 3 格入口)============
|
||||
m.vline(119, 15, 24, '#'); m.vline(126, 14, 27, '#');
|
||||
block(m, 121, 25, 2); block(m, 124, 22, 2); block(m, 121, 19, 2); block(m, 124, 16, 2);
|
||||
wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
|
||||
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
|
||||
m.rect(2, 14, 118, 1, '#');
|
||||
carpet(m, 9, 13, 108, [27, 59]); // 27=x36 / 59=x68:补对刺门通道列(否则整门是一面 3 连刺,往回跳必被 < 刺别住)
|
||||
ceil(m, 9, 116, 8);
|
||||
hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13);
|
||||
vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12);
|
||||
m.set(105, 13, 'G'); m.set(59, 13, 'G');
|
||||
// 苹果 3:对刺门后
|
||||
m.set(44, 12, 'F');
|
||||
// ============ 终点(上层左端)============
|
||||
m.set(10, 13, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
|
||||
// ============================================================ j10 毕业
|
||||
{
|
||||
id: 'j10', name: 'FINAL NEEDLE', cn: '毕业针刺', type: 'jump', diff: 5,
|
||||
hint: '四层大考场:刺毯长廊 → 冰面折返 → 矮洞 → 高空钢丝。全程无尿点。',
|
||||
width: 150,
|
||||
grid: (() => {
|
||||
const W = 150, H = 44;
|
||||
const m = new Grid(W, H);
|
||||
m.border('#');
|
||||
// ============ L0 刺毯长廊(地板 row 38,向右)============
|
||||
m.rect(1, 38, W - 2, 1, '#');
|
||||
m.set(3, 37, 'S');
|
||||
carpet(m, 5, 37, 134);
|
||||
ceil(m, 5, 138, 32);
|
||||
vgap(m, 20, 36); vgap(m, 48, 36); vgap(m, 76, 36);
|
||||
vgap(m, 104, 36); vgap(m, 128, 36);
|
||||
block(m, 34, 37, 2); block(m, 62, 37, 2);
|
||||
block(m, 90, 37, 2); block(m, 118, 37, 2);
|
||||
m.set(11, 37, 'G'); m.set(59, 37, 'G'); m.set(107, 37, 'G');
|
||||
// ============ 右井 A x140-147 → L1(左墙底部留 3 格入口)============
|
||||
m.vline(139, 29, 34, '#'); m.vline(147, 28, 37, '#');
|
||||
block(m, 141, 35, 2); block(m, 144, 32, 2); block(m, 141, 29, 2); block(m, 144, 26, 2);
|
||||
wall(m, 147, 34, 34, '<'); wall(m, 139, 31, 31, '>');
|
||||
// ============ L1 冰面层(地板 row 28,向左,延伸到井底)============
|
||||
m.rect(2, 28, 138, 1, '%');
|
||||
carpet(m, 11, 27, 126);
|
||||
m.set(136, 27, '.'); m.set(137, 27, '.'); m.set(138, 27, '.'); // 井 A 出口落点净空
|
||||
ceil(m, 11, 136, 22);
|
||||
vgap(m, 116, 26); vgap(m, 88, 26); vgap(m, 60, 26); vgap(m, 32, 26);
|
||||
block(m, 102, 27, 2); block(m, 74, 27, 2); block(m, 46, 27, 2);
|
||||
m.set(121, 27, 'G'); m.set(69, 27, 'G'); m.set(23, 27, 'G');
|
||||
// ============ 左井 B x2-8 → L2(右墙底部留 3 格入口)============
|
||||
m.vline(8, 17, 24, '#');
|
||||
block(m, 4, 25, 2); block(m, 2, 22, 2); block(m, 4, 19, 2); block(m, 5, 17, 2);
|
||||
wall(m, 8, 21, 21, '<'); wall(m, 2, 20, 20, '>'); wall(m, 8, 18, 18, '<');
|
||||
// ============ L2 矮洞层(地板 row 16,向右,延伸到井 C 底)============
|
||||
m.rect(8, 16, 138, 1, '#');
|
||||
m.rect(10, 12, 128, 1, '#'); // 矮洞顶(3 格高洞)
|
||||
carpet(m, 10, 15, 128);
|
||||
m.set(8, 15, '.'); m.set(9, 15, '.'); // 井 B 出口净空
|
||||
hgap(m, 72, 15);
|
||||
m.set(19, 15, 'G'); m.set(67, 15, 'G'); m.set(115, 15, 'G');
|
||||
// ============ 右井 C x140-147 → L3(左墙底部留 3 格入口,桥接块接 L3)============
|
||||
m.vline(139, 9, 12, '#'); m.vline(147, 8, 15, '#');
|
||||
block(m, 141, 13, 2); block(m, 144, 10, 2); block(m, 141, 7, 2);
|
||||
block(m, 137, 7, 4); // 桥:向左落到 L3 基座
|
||||
wall(m, 147, 12, 12, '<'); wall(m, 139, 10, 10, '>');
|
||||
// ============ L3 高空钢丝(基座 row 8 + 刺毯 row 7,开放高空,向左到终点)============
|
||||
m.rect(8, 8, 130, 1, '#');
|
||||
spikeRun(m, 9, 7, 128, '^'); // 刺毯基底
|
||||
for (let x = 133; x <= 137; x++) m.set(x, 7, '.'); // 井口落脚区
|
||||
// 钢丝柱:间距 3~6 不规则,柱顶 row 5~7 错落
|
||||
const wires = [];
|
||||
const steps = [4, 5, 3, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 6, 3, 5, 4, 6, 4, 3, 5, 4, 6, 3, 5, 4, 4, 3];
|
||||
let wx = 132;
|
||||
wires.push(wx);
|
||||
for (const s of steps) { wx -= s; if (wx < 14) break; wires.push(wx); }
|
||||
wires.forEach((x, i) => m.vline(x, [6, 5, 7, 6, 7, 5][i % 6], 7, '#'));
|
||||
m.vline(55, 5, 7, '#'); // 过渡柱:x58→x52 原为 6 格跳,HSPD 2.6 下"晚二段"落点整体左移 8px 掉过柱顶摔死;x55 拆成 3+3 格两跳(dj=12 轨迹实测两跳都能精确接住)
|
||||
// 存档放在两根钢丝顶上
|
||||
const g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
|
||||
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
|
||||
m.set(g1, topOf(6) - 1, 'G'); m.set(g2, topOf(wires.indexOf(g2)) - 1, 'G');
|
||||
m.rect(8, 7, 4, 1, '#'); // 终点台
|
||||
m.set(9, 6, 'X');
|
||||
return m.out();
|
||||
})(),
|
||||
},
|
||||
];
|
||||
|
||||
export default JUMP_LEVELS;
|
||||
@@ -0,0 +1,72 @@
|
||||
// 关卡地图工具:让手工 ASCII 不必逐格数点
|
||||
// G(w, rows) —— 每行不足宽度自动补 '.',最后一列强制为 '#'(外墙)
|
||||
export function G(w, rows) {
|
||||
return rows.map(r => {
|
||||
const s = r.length >= w ? r.slice(0, w) : r + '.'.repeat(w - r.length);
|
||||
return s.slice(0, w - 1) + '#';
|
||||
});
|
||||
}
|
||||
|
||||
// 重复字符:rep('%', 20)
|
||||
export const rep = (ch, n) => ch.repeat(Math.max(0, n | 0));
|
||||
|
||||
// 空格行
|
||||
export const E = w => '#' + rep('.', w - 2);
|
||||
|
||||
// 实心跳台:plat(w, 起列, 长度) => 一行带 '#####' 的字符串
|
||||
export function plat(w, x0, len, ch = '#') {
|
||||
if (x0 < 1) x0 = 1;
|
||||
return '#' + rep('.', x0 - 1) + rep(ch, len);
|
||||
}
|
||||
|
||||
// 实体行:ent(w, [列, 字符], ...) => 一行只在指定列放实体字符
|
||||
export function ent(w, ...items) {
|
||||
const a = new Array(w).fill('.');
|
||||
a[0] = '#';
|
||||
for (const [x, ch] of items) if (x > 0 && x < w - 1) a[x] = ch;
|
||||
return a.join('');
|
||||
}
|
||||
|
||||
// 地板行:从 col1 开始按 [字符, 长度] 铺满,最后一列由 G 补 '#'
|
||||
export function flr(...parts) {
|
||||
let s = '#';
|
||||
for (const [ch, n] of parts) s += rep(ch, n);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------- 2D 网格构建器 ----------------
|
||||
// 大地图用拼字符串极易出错(宽度对不齐 / 数错列),改用网格对象:
|
||||
// const m = new Map(120, 30); m.rect(x,y,w,h,'#'); ... m.out()
|
||||
export class Grid {
|
||||
constructor(w, h, fill = '.') {
|
||||
this.w = w; this.h = h;
|
||||
this.g = Array.from({ length: h }, () => new Array(w).fill(fill));
|
||||
}
|
||||
set(x, y, ch) {
|
||||
if (x >= 0 && x < this.w && y >= 0 && y < this.h) this.g[y][x] = ch;
|
||||
return this;
|
||||
}
|
||||
rect(x, y, w, h, ch) {
|
||||
for (let iy = y; iy < y + h; iy++)
|
||||
for (let ix = x; ix < x + w; ix++) this.set(ix, iy, ch);
|
||||
return this;
|
||||
}
|
||||
hline(y, x0, x1, ch) { for (let x = x0; x <= x1; x++) this.set(x, y, ch); return this; }
|
||||
vline(x, y0, y1, ch) { for (let y = y0; y <= y1; y++) this.set(x, y, ch); return this; }
|
||||
border(ch = '#') {
|
||||
this.hline(0, 0, this.w - 1, ch);
|
||||
this.hline(this.h - 1, 0, this.w - 1, ch);
|
||||
this.vline(0, 0, this.h - 1, ch);
|
||||
this.vline(this.w - 1, 0, this.h - 1, ch);
|
||||
return this;
|
||||
}
|
||||
out() { return this.g.map(r => r.join('')); }
|
||||
}
|
||||
|
||||
/** 落脚台序列:chain(起始列, 起始宽, [间隙...], 台宽) => [{x,w}, ...] */
|
||||
export function chain(x0, w0, gaps, padW = 2) {
|
||||
const out = [{ x: x0, w: w0 }];
|
||||
let x = x0 + w0;
|
||||
for (const g of gaps) { x += g; out.push({ x, w: padW }); x += padW; }
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 经典针刺阵型库(IWBT 风格):所有函数基于 Grid,坐标单位 = 格
|
||||
import { Grid } from './kit.js';
|
||||
|
||||
// 上下对刺夹缝(横向穿越 1 格高的缝):缝在行 y。
|
||||
// 自动清理两侧地毯刺作起跳/落点(否则有效跳距翻倍,无解)。
|
||||
export function vgap(m, x, y) {
|
||||
m.set(x, y - 1, 'v'); m.set(x, y + 1, '^');
|
||||
m.set(x - 1, y + 1, '.'); m.set(x + 1, y + 1, '.');
|
||||
m.set(x - 2, y + 1, '.'); m.set(x + 2, y + 1, '.');
|
||||
}
|
||||
// 左右对刺夹缝(竖向穿越 1 格宽的缝):缝在列 x,自动清理两侧落点。
|
||||
export function hgap(m, x, y) {
|
||||
m.set(x - 1, y, '>'); m.set(x + 1, y, '<');
|
||||
m.set(x - 2, y, '.'); m.set(x + 2, y, '.');
|
||||
}
|
||||
// 钻石阵:中心空格 + 四向刺(经典 diamond jump)
|
||||
// 注意:矩形判定盒下,完整钻石的横向穿越窗口只有几像素——
|
||||
// 作为"跳穿门"请用 vgap/hgap;diamond 只用作翻越/绕行的障碍或装饰。
|
||||
export function diamond(m, x, y) { vgap(m, x, y); hgap(m, x, y); }
|
||||
// 一排刺,gaps 为留空偏移(相对 x0)
|
||||
export function spikeRun(m, x0, y, n, ch = '^', gaps = []) {
|
||||
for (let i = 0; i < n; i++) if (!gaps.includes(i)) m.set(x0 + i, y, ch);
|
||||
}
|
||||
// 悬浮块,顶面铺刺(不可站)/ 或纯块
|
||||
export function block(m, x, y, w = 1, h = 1, ch = '#') { m.rect(x, y, w, h, ch); }
|
||||
// 悬浮块 + 顶刺
|
||||
export function spikedBlock(m, x, y, w = 1) { m.rect(x, y, w, 1, '#'); spikeRun(m, x, y - 1, w, '^'); }
|
||||
// 天花板垂刺
|
||||
export function ceil(m, x0, x1, y) { m.hline(y, x0, x1, 'v'); }
|
||||
// 墙刺竖段
|
||||
export function wall(m, x, y0, y1, ch) { m.vline(x, y0, y1, ch); }
|
||||
// 角刺块:w×h 实心块,某个外角带 L 形双刺(经典 corner)
|
||||
// dir: 'tl' 左上角 / 'tr' 右上 / 'bl' 左下 / 'br' 右下
|
||||
export function corner(m, x, y, w, h, dir) {
|
||||
m.rect(x, y, w, h, '#');
|
||||
if (dir === 'tl') { m.set(x, y - 1, '^'); m.set(x - 1, y, '>'); }
|
||||
if (dir === 'tr') { m.set(x + w - 1, y - 1, '^'); m.set(x + w, y, '<'); }
|
||||
if (dir === 'bl') { m.set(x, y + h, 'v'); m.set(x - 1, y + h - 1, '>'); }
|
||||
if (dir === 'br') { m.set(x + w - 1, y + h, 'v'); m.set(x + w, y + h - 1, '<'); }
|
||||
}
|
||||
// 之字踏石竖井(可解性已用 tools/solve.mjs 验证)
|
||||
// xL..xR 为井内列;topRow = 上层地板行,botRow = 下层地板行(差须为偶数且 ≥4)
|
||||
// floorSide: 'l' 上层地板在井的左侧(井在右侧)/ 'r' 反之
|
||||
// 规则:
|
||||
// 1) 踏石每级升 2 格(单跳 90px > 64px),左右交替,宽度 w 使两级有 2 格重叠
|
||||
// —— overlap 让"上升后横移"只需 1 格左右,避免横向距离超过滞空距离。
|
||||
// 2) 靠地板一侧的井壁从 topRow+1 起到 botRow-4,底部留 3 格入口。
|
||||
// 3) 顶台位于 topRow 且与上层地板紧邻(同高相接),不存在"洞口被顶台封死"。
|
||||
export function well(m, xL, xR, topRow, botRow, floorSide = 'l', opts = {}) {
|
||||
const w = opts.w || 5;
|
||||
let i = 0;
|
||||
for (let r = topRow; r <= botRow - 2; r += 2, i++) {
|
||||
const onLeft = (i % 2 === 0) === (floorSide === 'l');
|
||||
m.rect(onLeft ? xL : xR - w + 1, r, w, 1, '#');
|
||||
}
|
||||
const floorX = floorSide === 'l' ? xL - 1 : xR + 1;
|
||||
const backX = floorSide === 'l' ? xR + 1 : xL - 1;
|
||||
m.vline(backX, topRow, botRow, '#');
|
||||
m.vline(floorX, topRow + 1, botRow - 4, '#');
|
||||
return m;
|
||||
}
|
||||
|
||||
// 竖井:在 (x0..x1) 之间开竖直通道,交替放小平台,两侧可布墙刺
|
||||
// plats: [{y, side:'l'|'r', w}],spikeEvery: 每隔几行在对面墙上放刺
|
||||
export function shaft(m, x0, x1, yTop, yBot, plats, opts = {}) {
|
||||
for (const p of plats) {
|
||||
const px = p.side === 'l' ? x0 : x1 - p.w + 1;
|
||||
m.rect(px, p.y, p.w, 1, '#');
|
||||
if (p.spike) spikeRun(m, px, p.y - 1, p.w, '^', p.spikeGaps || []);
|
||||
}
|
||||
if (opts.wallSpikes) {
|
||||
const { side = 'r', every = 3, from = yTop + 1, to = yBot - 1 } = opts.wallSpikes;
|
||||
for (let y = from; y <= to; y += every) {
|
||||
if (plats.some(p => Math.abs(p.y - y) <= 1)) continue;
|
||||
m.set(side === 'r' ? x1 : x0, y, side === 'r' ? '<' : '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
export { Grid };
|
||||
@@ -0,0 +1,91 @@
|
||||
// 坑向(Troll / Trap)关卡:背叛玩家的一切直觉
|
||||
import { G, rep, E, plat, ent, flr } from './kit.js';
|
||||
|
||||
export default [
|
||||
// ---------------- 1 ----------------
|
||||
{
|
||||
id: 'r1', name: 'TRUST NO ONE', type: 'troll', diff: 3, bg: 'troll', width: 56,
|
||||
hint: '路牌在骗你。那两颗"补给"也不怀好意。地板会塌。',
|
||||
messages: ['← 地板很结实,放心走 →', '这颗苹果看起来很好吃'],
|
||||
grid: G(56, [
|
||||
rep('#', 56), // 0
|
||||
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
|
||||
E(56), E(56), E(56), E(56), E(56), E(56), // 7-12
|
||||
E(56), // 13
|
||||
ent(56, [20, 'D'], [38, 'D']), // 14
|
||||
E(56), // 15
|
||||
E(56), // 16
|
||||
ent(56, [2, 'S'], [5, 'T'], [9, '*'], [22, 'G'], [36, '*'], [48, 'T'], [52, 'X']), // 17
|
||||
flr(['#', 10], ['V', 8], ['#', 8], ['^', 5], ['#', 9], ['V', 6], ['#', 9]), // 18
|
||||
]),
|
||||
},
|
||||
|
||||
// ---------------- 2 ----------------
|
||||
{
|
||||
id: 'r2', name: 'FALSE GOAL', type: 'troll', diff: 4, bg: 'troll', width: 56,
|
||||
hint: '地上那扇门是假的——走过去就死。真正的终点在右上角。',
|
||||
messages: ['终点就在前面!→', '真的在上面。'],
|
||||
grid: G(56, [
|
||||
rep('#', 56), // 0
|
||||
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
|
||||
ent(56, [50, 'X']), // 7
|
||||
plat(56, 48, 5), // 8
|
||||
E(56), // 9
|
||||
plat(56, 44, 5), // 10
|
||||
E(56), // 11
|
||||
plat(56, 38, 5), // 12
|
||||
E(56), // 13
|
||||
plat(56, 44, 5), // 14
|
||||
E(56), // 15
|
||||
plat(56, 38, 5), // 16
|
||||
ent(56, [2, 'S'], [8, 'T'], [15, 'G'], [24, '^'], [25, '^'],
|
||||
[30, 'X'], [36, 'G']), // 17
|
||||
rep('#', 56), // 18
|
||||
]),
|
||||
events: [
|
||||
{ x: 28, y: 14, w: 6, h: 5, once: false, actions: [{ type: 'kill' }] },
|
||||
],
|
||||
},
|
||||
|
||||
// ---------------- 3 ----------------
|
||||
{
|
||||
id: 'r3', name: 'BLIND', type: 'troll', diff: 4, bg: 'troll', width: 56,
|
||||
hint: '摄像头接管了视野:你只能看见身边一小块地方。天上下刺也看不见。',
|
||||
messages: ['摄像头已启动 ★'],
|
||||
grid: G(56, [
|
||||
rep('#', 56), // 0
|
||||
E(56), E(56), E(56), E(56), E(56), E(56), // 1-6
|
||||
E(56), E(56), E(56), E(56), E(56), E(56), // 7-12
|
||||
E(56), // 13
|
||||
ent(56, [18, 'D'], [30, 'D'], [44, 'D']), // 14
|
||||
E(56), // 15
|
||||
E(56), // 16
|
||||
ent(56, [2, 'S'], [6, 'T'], [15, 'G'], [32, 'G'], [45, 'X']), // 17
|
||||
flr(['#', 8], ['^', 3], ['#', 10], ['^', 4], ['#', 12], ['^', 3], ['#', 9], ['^', 4], ['#', 2]), // 18
|
||||
]),
|
||||
camzones: [{ x: 1, y: 14, w: 54, h: 5, mode: 'zoom', zoom: 2.6 }],
|
||||
},
|
||||
|
||||
// ---------------- 4 ----------------
|
||||
{
|
||||
id: 'r4', name: 'TROLL PARADISE', type: 'troll', diff: 5, bg: 'troll', width: 64,
|
||||
hint: '漩涡送你回起点,"补给"会杀你,地板会塌,天上还会下刺雨。',
|
||||
messages: ['这是最后的考验(笑)', '前面的门是假的'],
|
||||
grid: G(64, [
|
||||
rep('#', 64), // 0
|
||||
E(64), E(64), E(64), E(64), E(64), E(64), // 1-6
|
||||
E(64), E(64), E(64), E(64), E(64), E(64), // 7-12
|
||||
E(64), // 13
|
||||
ent(64, [12, 'D'], [25, 'D'], [38, 'D'], [55, 'D']), // 14
|
||||
E(64), // 15
|
||||
E(64), // 16
|
||||
ent(64, [2, 'S'], [5, 'T'], [7, '*'], [19, 'G'], [30, '*'],
|
||||
[44, 'W'], [47, 'T'], [58, 'X']), // 17
|
||||
flr(['#', 8], ['V', 6], ['#', 8], ['^', 4], ['#', 8], ['V', 6], ['#', 8], ['^', 3], ['#', 12]), // 18
|
||||
]),
|
||||
warps: [{ to: 'spawn' }],
|
||||
events: [
|
||||
{ x: 28, y: 14, w: 5, h: 5, once: false, actions: [{ type: 'spikes', x: 26, y: 0, count: 8, gap: 2 }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
// 教学关(短,用来熟悉手感)
|
||||
import { G, rep, E, plat, ent } from './kit.js';
|
||||
|
||||
export default [
|
||||
{
|
||||
id: 't1', name: 'FIRST STEPS', type: 'jump', diff: 1, bg: 'jump', width: 36,
|
||||
hint: '← → 移动,Shift / Z / 空格跳跃。刺碰到就死,R 键重来。',
|
||||
messages: ['← → 移动 Shift 跳跃 R 重来'],
|
||||
grid: G(36, [
|
||||
rep('#', 36),
|
||||
E(36), E(36), E(36), E(36), E(36), E(36),
|
||||
ent(36, [30, 'X']),
|
||||
plat(36, 29, 5),
|
||||
E(36),
|
||||
plat(36, 26, 5),
|
||||
E(36),
|
||||
plat(36, 22, 5),
|
||||
E(36),
|
||||
plat(36, 18, 5),
|
||||
E(36),
|
||||
plat(36, 13, 5),
|
||||
ent(36, [2, 'S'], [4, 'T'], [7, '^'], [8, '^'], [9, '^'], [22, 'G']),
|
||||
rep('#', 36),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 't2', name: 'DOUBLE JUMP', type: 'jump', diff: 1, bg: 'custom', width: 40,
|
||||
hint: '空中再按一次跳跃=二段跳。5 格宽的刺坑只能靠二段跳过去。',
|
||||
messages: ['空中再按一次 Shift = 二段跳'],
|
||||
grid: G(40, [
|
||||
rep('#', 40),
|
||||
E(40), E(40), E(40), E(40), E(40), E(40), E(40),
|
||||
ent(40, [19, 'X']),
|
||||
plat(40, 18, 4),
|
||||
E(40),
|
||||
plat(40, 26, 4),
|
||||
E(40),
|
||||
plat(40, 18, 4),
|
||||
E(40),
|
||||
plat(40, 26, 4),
|
||||
E(40),
|
||||
plat(40, 18, 4),
|
||||
ent(40, [2, 'S'], [4, 'T'], [11, '^'], [12, '^'], [13, '^'], [14, '^'], [15, '^'], [19, 'G']),
|
||||
rep('#', 40),
|
||||
]),
|
||||
},
|
||||
];
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
// 入口:游戏循环 / 状态机
|
||||
import { LEVELS, findLevel } from './levels/index.js';
|
||||
import { Level } from './level.js';
|
||||
import { Player } from './player.js';
|
||||
import { Camera } from './camera.js';
|
||||
import { Input } from './input.js';
|
||||
import { UI } from './ui.js';
|
||||
import { Save } from './save.js';
|
||||
import { Touch } from './touch.js';
|
||||
import { sfx, unlockAudio, Audio2 } from './audio.js';
|
||||
import { Editor } from './editor.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { clamp } from './util.js';
|
||||
|
||||
const W = 800, H = 608;
|
||||
const STEP = 1000 / 60;
|
||||
|
||||
// 移动端检测:触屏设备自动启用虚拟手柄;?touch=1 强制开启,?touch=0 强制关闭(桌面调试用)
|
||||
// 判定条件:① 是移动端(存在 ontouchstart)/ 支持触屏(maxTouchPoints>0)/ 粗指针(触屏笔或手指)
|
||||
// 只有命中才给 <html> 加 .touch 类;CSS 里 html:not(.touch) #touch 会强制隐藏,桌面端永远不显示按钮
|
||||
const _qp = new URLSearchParams(location.search);
|
||||
const _force = _qp.get('touch');
|
||||
const _isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || matchMedia('(pointer: coarse)').matches;
|
||||
const useTouch = _force === '1' || (_force !== '0' && _isTouch);
|
||||
if (useTouch) document.documentElement.classList.add('touch');
|
||||
function showTouch() { if (useTouch) Touch.show(); }
|
||||
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const G = {
|
||||
state: 'menu', // menu | playing | paused | result | editor
|
||||
level: null, player: null, cam: null,
|
||||
data: null, idx: -1,
|
||||
map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标
|
||||
fromEditor: false, // 是否从编辑器「试玩」进入(决定暂停菜单末项去向)
|
||||
deaths: 0, ms: 0, hudTick: 0,
|
||||
time: 0,
|
||||
lastLevelId: null,
|
||||
};
|
||||
|
||||
// ---------------- 关卡流程 ----------------
|
||||
// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors)
|
||||
function faceData(map, face, i) {
|
||||
return {
|
||||
id: (map.id || 'map') + '-' + i,
|
||||
name: face.name || ('面' + (i + 1)),
|
||||
type: 'custom',
|
||||
width: face.width || (face.grid[0] ? face.grid[0].length : 25),
|
||||
grid: face.grid,
|
||||
doors: face.doors || [],
|
||||
bg: 'custom', hint: '', messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function startLevel(data, idx = -1) {
|
||||
unlockAudio();
|
||||
G.data = data; G.idx = idx; G.lastLevelId = data.id;
|
||||
// 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理
|
||||
if (data.faces && data.faces.length) {
|
||||
G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; });
|
||||
G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1));
|
||||
} else {
|
||||
G.map = [new Level(data)]; G.face = 0;
|
||||
}
|
||||
G.level = G.map[G.face];
|
||||
G.player = new Player(G.level);
|
||||
G.cam = new Camera(W, H);
|
||||
G.cam.reset(G.level, G.player);
|
||||
G.player.onDeath = () => { G.deaths++; };
|
||||
G.player.onClear = () => finish();
|
||||
G.player.onPortal = (e) => doPortal(e);
|
||||
G.player.onRespawnFace = () => doRespawnFace();
|
||||
G.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
|
||||
G.deaths = 0; G.ms = 0; G.hudTick = 0;
|
||||
G.state = 'playing';
|
||||
UI.showGame();
|
||||
showTouch();
|
||||
UI.toast('');
|
||||
if (data.hint) UI.toast(data.hint);
|
||||
}
|
||||
|
||||
// 把玩家放到某面某门上(不切面的同面传送也用)
|
||||
function placeOnDoor(dest) {
|
||||
const P = G.player;
|
||||
P.x = dest.x + dest.w / 2 - P.w / 2;
|
||||
P.y = dest.y + dest.h - P.h;
|
||||
P.vx = 0; P.vy = 0; P.jumps = 0;
|
||||
}
|
||||
|
||||
// 踩到门:e.to = {face, door}
|
||||
function doPortal(e) {
|
||||
const to = e.to;
|
||||
const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0));
|
||||
if (tp === G.face) {
|
||||
const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door);
|
||||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||||
sfx.warp();
|
||||
} else {
|
||||
switchFace(tp, to.door);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到第 f 面,并落到该面的目标门上
|
||||
function switchFace(f, doorId) {
|
||||
G.face = f;
|
||||
G.level = G.map[f];
|
||||
G.level.reset(true); // 进入该面时重置其运行时
|
||||
G.player.level = G.level;
|
||||
const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId);
|
||||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||||
else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; }
|
||||
G.cam.reset(G.level, G.player);
|
||||
sfx.warp();
|
||||
G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
|
||||
}
|
||||
|
||||
// 复活回到存档点所在面(跨面)
|
||||
function doRespawnFace() {
|
||||
const sp = G.player.savePoint;
|
||||
if (sp && typeof sp.face === 'number' && sp.face !== G.face) {
|
||||
G.face = sp.face;
|
||||
G.level = G.map[sp.face];
|
||||
G.level.reset(true);
|
||||
G.player.level = G.level;
|
||||
G.cam.reset(G.level, G.player);
|
||||
}
|
||||
}
|
||||
|
||||
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
|
||||
function restart() {
|
||||
if (!G.data) return;
|
||||
const d = G.deaths;
|
||||
startLevel(G.data, G.idx);
|
||||
G.deaths = d; // 死亡数不重置
|
||||
}
|
||||
|
||||
/** R 键 / 触屏重生键:直接重生回最近的存档点——死亡中按 = 立即复活;活着按 = 原地播死亡动画后当帧重生 */
|
||||
function suicide() {
|
||||
if (!G.player || G.state !== 'playing') return;
|
||||
if (G.player.dead) G.player.respawn();
|
||||
// 活着按:原地播死亡动画(粒子+轻震+冲击环),但不留红叉、不计死亡数,当帧就回存档点——
|
||||
// 不等待,特效由 Level 层留在死亡位置自然播完(重生不清粒子/震屏)
|
||||
else { G.player.deathBurst(); G.player.respawn(); }
|
||||
}
|
||||
|
||||
/** 触屏射击:仅在持枪、未死亡且游戏进行中时发射(受玩家自身冷却限制) */
|
||||
function shoot() {
|
||||
const P = G.player;
|
||||
if (G.state !== 'playing' || !P || !P.alive || !P.hasGun || P.shootCool !== 0) return;
|
||||
P.shoot();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (G.state !== 'playing') return;
|
||||
G.state = 'result';
|
||||
Touch.hide();
|
||||
sfx.goal();
|
||||
const apples = G.player.apples || 0;
|
||||
const total = G.level.totalApples || 0;
|
||||
const better = Save.clear(G.data.id, G.deaths, G.ms, apples);
|
||||
const hasNext = G.idx >= 0 && G.idx + 1 < LEVELS.length;
|
||||
UI.result({
|
||||
name: G.data.name, ms: G.ms, deaths: G.deaths,
|
||||
apples, totalApples: total, better, hasNext,
|
||||
});
|
||||
}
|
||||
|
||||
function nextLevel() {
|
||||
if (G.idx >= 0 && G.idx + 1 < LEVELS.length) startLevel(LEVELS[G.idx + 1], G.idx + 1);
|
||||
else toLevels();
|
||||
}
|
||||
function toLevels() { G.state = 'menu'; UI.show('scr-levels'); Touch.hide(); }
|
||||
function toTitle() { G.state = 'menu'; UI.show('scr-title'); Touch.hide(); }
|
||||
function toEditor() {
|
||||
G.state = 'editor';
|
||||
UI.show('scr-editor');
|
||||
Touch.hide();
|
||||
// 试玩后返回:保留正在编辑的地图;从未进过编辑器时才新建
|
||||
if (Editor.faces) Editor.show(); else Editor.open();
|
||||
}
|
||||
function pause() {
|
||||
if (G.state !== 'playing') return;
|
||||
G.state = 'paused';
|
||||
// 暂停菜单末项:从编辑器试玩进来 → 「返回编辑器」;否则 → 「返回关卡选择」
|
||||
const q = document.querySelector('#scr-pause [data-act="quit"]');
|
||||
if (q) q.textContent = G.fromEditor ? '返回编辑器' : '返回关卡选择';
|
||||
UI.show('scr-pause');
|
||||
Touch.hide();
|
||||
}
|
||||
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); showTouch(); } }
|
||||
|
||||
// ---------------- 更新 ----------------
|
||||
function update() {
|
||||
const P = G.player;
|
||||
// R = 重生回存档点;Backspace = 整关重开
|
||||
if (Input.hit('suicide')) { suicide(); return; }
|
||||
if (Input.hit('restart')) { restart(); return; }
|
||||
if (Input.hit('pause')) { pause(); return; }
|
||||
|
||||
const wasDead = P.dead;
|
||||
G.level.update(1, P, G.cam);
|
||||
for (const b of G.level.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
|
||||
if (P.justRespawned) {
|
||||
if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace();
|
||||
G.cam.snapTo(P, G.level);
|
||||
P.justRespawned = false;
|
||||
}
|
||||
G.cam.update(G.level, P, 1);
|
||||
G.ms += STEP;
|
||||
G.time++;
|
||||
|
||||
if (++G.hudTick % 5 === 0) {
|
||||
const L = G.level;
|
||||
UI.hud({
|
||||
name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''),
|
||||
deaths: G.deaths, ms: G.ms,
|
||||
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
|
||||
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
|
||||
});
|
||||
Touch.setGun(P.hasGun);
|
||||
const boss = L.bosses.find(b => !b.dead) || L.bosses[0];
|
||||
UI.bossBar(L.bosses.length ? boss : null);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 渲染 ----------------
|
||||
function render() {
|
||||
const t = G.time;
|
||||
if (G.state === 'playing' || G.state === 'paused' || G.state === 'result') {
|
||||
const L = G.level, P = G.player, C = G.cam;
|
||||
ctx.fillStyle = '#0a0a14';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
L.drawBg(ctx, C, t);
|
||||
ctx.save();
|
||||
C.apply(ctx);
|
||||
L.draw(ctx, C, t);
|
||||
for (const b of L.bosses) b.draw(ctx, t);
|
||||
P.draw(ctx);
|
||||
ctx.restore();
|
||||
L.drawOverlay(ctx, C, t);
|
||||
UI.toast(L.toast || '');
|
||||
// 死亡中:提示手动重生(呼吸闪烁,不抢死亡动画镜头)
|
||||
if (G.state === 'playing' && P.dead) {
|
||||
const blink = Math.sin(t / 12) > -0.3;
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.globalAlpha = blink ? 1 : 0.3;
|
||||
ctx.fillStyle = '#ff8fa3';
|
||||
ctx.font = 'bold 20px monospace';
|
||||
ctx.fillText(useTouch ? '按右下角重生键' : '按 R 重生', W / 2, H / 2 - 36);
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = '#a8a8c0';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText('Backspace 整关重来', W / 2, H / 2 - 12);
|
||||
ctx.restore();
|
||||
}
|
||||
} else {
|
||||
drawTitleBg(t);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTitleBg(t) {
|
||||
const g = ctx.createLinearGradient(0, 0, 0, H);
|
||||
g.addColorStop(0, '#181828'); g.addColorStop(1, '#08080f');
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
||||
// 漂浮的刺
|
||||
ctx.globalAlpha = 0.5;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const x = ((i * 137 + t * 0.15) % (W + 60)) - 30;
|
||||
const y = 120 + ((i * 71) % (H - 160));
|
||||
ctx.fillStyle = i % 3 === 0 ? '#3a3a4c' : '#2a2a38';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + 18); ctx.lineTo(x + 11, y); ctx.lineTo(x + 22, y + 18);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
// 底部刺海
|
||||
ctx.fillStyle = '#1a1a26';
|
||||
ctx.fillRect(0, H - 30, W, 30);
|
||||
ctx.fillStyle = '#c8c8d8';
|
||||
for (let x = 0; x < W; x += 22) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, H - 4); ctx.lineTo(x + 11, H - 26); ctx.lineTo(x + 22, H - 4);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 主循环 ----------------
|
||||
let last = performance.now(), acc = 0;
|
||||
function loop(now) {
|
||||
requestAnimationFrame(loop);
|
||||
let dt = now - last; last = now;
|
||||
if (dt > 200) dt = 200;
|
||||
acc += dt;
|
||||
let guard = 0;
|
||||
while (acc >= STEP && guard++ < 6) {
|
||||
acc -= STEP;
|
||||
if (G.state === 'playing') update();
|
||||
else if (G.state === 'paused' && Input.hit('pause')) resume();
|
||||
Input.endFrame();
|
||||
}
|
||||
if (G.state !== 'playing') G.time++;
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------------- 启动 ----------------
|
||||
let settingsFrom = 'scr-title'; // 进入设置前的界面,确定后返回
|
||||
Save.load();
|
||||
Audio2.enabled = Save.data.audio !== false;
|
||||
|
||||
UI.init({
|
||||
play: (lv) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
|
||||
levels: toLevels,
|
||||
manage: () => UI.setManage(!UI.manage),
|
||||
editor: toEditor,
|
||||
settings: () => { settingsFrom = UI.cur; Settings.enter(); UI.show('scr-settings'); },
|
||||
'set-ok': () => { Settings.confirm(); UI.show(settingsFrom); },
|
||||
'set-reset': () => Settings.reset(),
|
||||
back: toTitle,
|
||||
resume,
|
||||
restart: () => { restart(); },
|
||||
quit: () => (G.fromEditor ? toEditor() : toLevels()),
|
||||
retry: () => startLevel(G.data, G.idx),
|
||||
next: nextLevel,
|
||||
wipe: () => {
|
||||
if (confirm('确定清除所有通关记录与自制关卡?')) { Save.wipe(); UI.renderLevels(); }
|
||||
},
|
||||
});
|
||||
|
||||
Editor.init({
|
||||
startLevel: (data) => { G.fromEditor = true; startLevel(data, -1); },
|
||||
refresh: () => UI.renderLevels(),
|
||||
});
|
||||
|
||||
// 首次交互解锁音频
|
||||
const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); };
|
||||
addEventListener('pointerdown', unlock);
|
||||
addEventListener('keydown', unlock);
|
||||
|
||||
// 移动端虚拟手柄
|
||||
Touch.init({ pause, suicide, shoot });
|
||||
Settings.init(useTouch);
|
||||
|
||||
UI.show('scr-title');
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
// 调试钩子
|
||||
window.__IW = { G, LEVELS, Save, startLevel, pause, resume, restart, suicide };
|
||||
@@ -0,0 +1,44 @@
|
||||
// 粒子系统
|
||||
import { rand, randInt } from './util.js';
|
||||
|
||||
export class Particles {
|
||||
constructor() { this.list = []; }
|
||||
clear() { this.list.length = 0; }
|
||||
|
||||
spawn(x, y, n, opt = {}) {
|
||||
const {
|
||||
colors = ['#fff', '#ff5c7a', '#ffc63a'],
|
||||
speed = 3, life = 40, size = 3, grav = 0.12, spread = Math.PI * 2, dir = 0,
|
||||
} = opt;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = dir + (Math.random() - 0.5) * spread;
|
||||
const s = rand(speed * 0.35, speed);
|
||||
this.list.push({
|
||||
x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
|
||||
life: life * rand(0.6, 1.25), max: life,
|
||||
c: colors[randInt(0, colors.length - 1)],
|
||||
s: size * rand(0.6, 1.3), g: grav,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
burst(x, y, opt = {}) { this.spawn(x, y, opt.n || 18, opt); }
|
||||
|
||||
update() {
|
||||
for (let i = this.list.length - 1; i >= 0; i--) {
|
||||
const p = this.list[i];
|
||||
p.x += p.vx; p.y += p.vy; p.vy += p.g;
|
||||
p.vx *= 0.985;
|
||||
if (--p.life <= 0) this.list.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
for (const p of this.list) {
|
||||
ctx.globalAlpha = Math.min(1, p.life / (p.max * 0.5));
|
||||
ctx.fillStyle = p.c;
|
||||
ctx.fillRect(p.x | 0, p.y | 0, p.s | 0, p.s | 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// 玩家:还原 IWBT 的硬核手感 —— 松手即停、无加速、可变跳跃高度、二段跳
|
||||
import { TILE, clamp, sgn, overlap } from './util.js';
|
||||
import { Input } from './input.js';
|
||||
import { sfx } from './audio.js';
|
||||
import * as S from './sprites.js';
|
||||
import { T } from './level.js';
|
||||
|
||||
export const PHY = {
|
||||
GRAV: 0.25, // 上升段重力:小 = 跳跃飘
|
||||
GRAV_FALL: 0.32, // 下落段重力:小 = 下坠轻、飘落感(0.35→0.32,用户反馈"下落有点沉")
|
||||
GRAV_DJ: 0.35, // 二段跳上升段专属重力:比 GRAV 稍重——初速 6.7 与一段 6.6 一致,配 0.35 才能又冲(不起悬停)又只有 4.5 格总高
|
||||
MAXFALL: 9,
|
||||
JUMP: 6.6,
|
||||
DJUMP: 6.7, // 二段跳冲量:起跳初速与一段 JUMP 6.6 几乎一致(起跳"嘭"感同款);顶点接二段总高 = 4.51 格
|
||||
DJ_INERTIA: 0, // 二段跳时下落速度对冲量的抵消比例:0=任何时机接二段都满力起跳(初速恒 -DJUMP,同 IWBT 原版),
|
||||
// 1=全额抵消(旧 bug:顶点晚几帧接二段直接归零)。0.35/0.5 都被用户反馈"好沉+短"——起跳初速被下落速度
|
||||
// 吃掉才是"沉"的根源(初速从 6.7 掉到 3~4,起跳那一下没劲),与上升重力无关。
|
||||
HSPD: 2.6,
|
||||
WATER_GRAV: 0.16,
|
||||
WATER_FALL: 2.6,
|
||||
WATER_RISE: 0.42,
|
||||
ICE_ACC: 0.18,
|
||||
ICE_FRIC: 0.07,
|
||||
// 滑出冰块后的惯性:空中几乎不损失,地面缓慢收住
|
||||
SLIDE_FRIC: 0.12, // 非冰地面上收住惯性的摩擦
|
||||
SLIDE_AIR: 0.02, // 空中惯性衰减(≈0,滑出冰块边缘能一路飞出去)
|
||||
SLIDE_EASE: 0.14, // 超速滑行时按住同向键,收敛回正常走速的速度
|
||||
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
|
||||
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
|
||||
SLIDE_STOP: 0.05, // 低于此速度惯性结束
|
||||
ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
|
||||
};
|
||||
|
||||
export class Player {
|
||||
constructor(level) {
|
||||
this.level = level;
|
||||
// IWBT 官方判定盒:11×21 像素(比精灵视觉略小,支持像素级擦弹/贴刺)
|
||||
this.w = 11; this.h = 21;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
reset(full) {
|
||||
const sp = full ? this.level.spawn : (this.savePoint || this.level.spawn);
|
||||
this.x = sp.x; this.y = sp.y;
|
||||
this.vx = 0; this.vy = 0;
|
||||
this.jumps = 0;
|
||||
this.djUp = false;
|
||||
this.grounded = false;
|
||||
this.facing = 1;
|
||||
this.alive = true;
|
||||
this.dead = false;
|
||||
this.deadT = 0;
|
||||
this.deathX = 0; this.deathY = 0; // 死亡动画圆心(掉坑死亡时 clamp 到关卡内)
|
||||
this.inWater = false;
|
||||
this.animT = 0;
|
||||
this.shootCool = 0;
|
||||
this.mover = null;
|
||||
this.sliding = false; // 冰面滑行惯性状态:离开冰块后仍保留
|
||||
this.justRespawned = false; // 本帧刚复活(供镜头瞬移)
|
||||
if (full) {
|
||||
this.hasGun = !!this.level.data.startGun;
|
||||
this.apples = 0;
|
||||
this.savePoint = null;
|
||||
this.saveIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
get cx() { return this.x + this.w / 2; }
|
||||
get cy() { return this.y + this.h / 2; }
|
||||
|
||||
// 纯死亡演出(不进死亡状态):粒子爆开 + 屏幕轻震 + 死亡点扩散冲击环(Level 层,见 addDeathFX)。
|
||||
// 主动按重生键时直接调它再当帧 respawn——特效留在死亡位置自然播完。
|
||||
// 圆心 clamp 到关卡内:掉坑死亡时玩家已在屏幕外,保证红叉/粒子/圆环可见
|
||||
deathBurst() {
|
||||
const L = this.level;
|
||||
const dx = clamp(this.cx, 10, Math.max(10, L.pxW - 10));
|
||||
const dy = clamp(this.cy, 10, Math.max(10, L.pxH - 10));
|
||||
sfx.death();
|
||||
L.particles.spawn(dx, dy, 16, {
|
||||
colors: ['#ff5c7a', '#ff8095', '#fff', '#ffc63a'], speed: 3.4, life: 34, size: 3, grav: 0.16,
|
||||
});
|
||||
L.shake = Math.max(L.shake || 0, 6);
|
||||
L.addDeathFX(dx, dy);
|
||||
return { dx, dy };
|
||||
}
|
||||
|
||||
// 死亡:定格在原地,等玩家手动按重生键(R / 触屏重生键)。
|
||||
// 视觉:① 死亡地点留下常驻红叉(IWBTG 血迹,复活后仍在;opts.mark=false 时不留)
|
||||
// ② deathBurst() 的"小"死亡动画(粒子+轻震+冲击环)
|
||||
// opts:
|
||||
// mark=false → 不留红叉
|
||||
// count=false → 不触发 onDeath 计数(不计入死亡数)
|
||||
kill(opts = {}) {
|
||||
if (this.dead || !this.alive) return;
|
||||
this.dead = true; this.alive = false; this.deadT = 0;
|
||||
this.vx = 0; this.vy = 0; this.sliding = false;
|
||||
const { dx, dy } = this.deathBurst();
|
||||
this.deathX = dx; this.deathY = dy;
|
||||
if (opts.mark !== false) this.level.addDeathMark(dx, dy);
|
||||
if (opts.count !== false && this.onDeath) this.onDeath();
|
||||
}
|
||||
|
||||
respawn() {
|
||||
this.dead = false; this.alive = true;
|
||||
const L = this.level;
|
||||
const sp = this.savePoint || L.spawn;
|
||||
this.x = sp.x; this.y = sp.y;
|
||||
this.vx = 0; this.vy = 0; this.jumps = 0;
|
||||
this.djUp = false;
|
||||
this.shootCool = 0; this.sliding = false;
|
||||
this.deadT = 0;
|
||||
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
|
||||
let guard = 0;
|
||||
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
|
||||
L.softReset(); // 不清粒子/震屏:死亡特效留在死亡位置自然播完
|
||||
this.justRespawned = true; // 供 main.js 把镜头直接吸附到重生点
|
||||
}
|
||||
|
||||
// ---------------- 每帧更新 ----------------
|
||||
update(dt) {
|
||||
const L = this.level;
|
||||
this.animT++;
|
||||
if (this.shootCool > 0) this.shootCool--;
|
||||
|
||||
// 死亡:定格在原地等玩家手动重生(R 键 / 触屏重生键),
|
||||
// 世界继续运转(敌人/子弹/粒子照走),但玩家不响应任何输入
|
||||
if (this.dead) { this.deadT++; return; }
|
||||
|
||||
const inWater = L.waterRect(this.x + 2, this.y + 4, this.w - 4, this.h - 4);
|
||||
if (inWater && !this.inWater) sfx.splash();
|
||||
this.inWater = inWater;
|
||||
|
||||
// ---- 水平输入(IWBT:普通地面瞬时、无惯性;冰面打滑并保留惯性)----
|
||||
const moveL = Input.is('left'), moveR = Input.is('right');
|
||||
const onIce = this.grounded && L.iceAtBottom(this.x, this.y, this.w, this.h);
|
||||
let target = 0;
|
||||
if (moveL && !moveR) target = -PHY.HSPD;
|
||||
else if (moveR && !moveL) target = PHY.HSPD;
|
||||
|
||||
const ICE_MAX = PHY.HSPD * PHY.ICE_TOP;
|
||||
const decel = f => {
|
||||
if (Math.abs(this.vx) <= f) this.vx = 0;
|
||||
else this.vx -= sgn(this.vx) * f;
|
||||
};
|
||||
|
||||
if (onIce) {
|
||||
// 冰面:加速/减速都很慢,速度上限也更高
|
||||
if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC;
|
||||
else decel(PHY.ICE_FRIC);
|
||||
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
|
||||
// 攒出一定速度即进入「滑行」状态——这是惯性得以保留的关键
|
||||
if (Math.abs(this.vx) > PHY.SLIDE_MIN) this.sliding = true;
|
||||
} else if (this.sliding && Math.abs(this.vx) > PHY.SLIDE_STOP) {
|
||||
// 滑出冰块后:惯性依然保留 —— 空中几乎不衰减,地面才慢慢收住
|
||||
if (target !== 0) {
|
||||
const dir = sgn(this.vx);
|
||||
if (dir === 0 || sgn(target) === dir) {
|
||||
if (Math.abs(this.vx) >= PHY.HSPD) {
|
||||
// 还在超速滑行:同向按键只让它缓慢收敛回正常走速,不再继续加速
|
||||
this.vx -= dir * PHY.SLIDE_EASE;
|
||||
if (Math.abs(this.vx) < PHY.HSPD) { this.vx = target; this.sliding = false; }
|
||||
} else {
|
||||
this.vx = target; // 已低于走速:交还玩家控制
|
||||
this.sliding = false;
|
||||
}
|
||||
} else {
|
||||
this.vx -= dir * PHY.SLIDE_BRAKE; // 反向按键:刹车,刹住后立刻反向
|
||||
if (sgn(this.vx) === sgn(target)) { this.vx = target; this.sliding = false; }
|
||||
}
|
||||
this.vx = clamp(this.vx, -ICE_MAX, ICE_MAX);
|
||||
} else {
|
||||
decel(this.grounded ? PHY.SLIDE_FRIC : PHY.SLIDE_AIR);
|
||||
if (this.vx === 0) this.sliding = false;
|
||||
}
|
||||
if (this.vx !== 0 && Math.abs(this.vx) < PHY.SLIDE_STOP) { this.vx = 0; this.sliding = false; }
|
||||
} else {
|
||||
// 常规地面/空中(非滑行):IWBT 标准手感,松手即停、无惯性
|
||||
this.vx = target;
|
||||
this.sliding = false;
|
||||
}
|
||||
if (target !== 0) this.facing = sgn(target);
|
||||
|
||||
// ---- 跳跃 ----
|
||||
if (inWater) {
|
||||
if (Input.is('jump')) this.vy -= PHY.WATER_RISE;
|
||||
if (Input.is('down')) this.vy += 0.25;
|
||||
this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL);
|
||||
this.jumps = 0; this.djUp = false;
|
||||
} else {
|
||||
if (Input.hit('jump')) {
|
||||
if (this.grounded || this.jumps === 0) {
|
||||
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
|
||||
// 不再做起跳拉伸:空中保持身体比例,避免跳跃时视觉上下晃动
|
||||
sfx.jump();
|
||||
L.particles.spawn(this.cx, this.y + this.h, 5,
|
||||
{ colors: ['#fff', '#cfcfe0'], speed: 1.6, life: 16, size: 2, dir: -Math.PI / 2, spread: 2 });
|
||||
} else if (this.jumps === 1) {
|
||||
// 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -DJUMP);
|
||||
// 下落中按 = 下落惯性只抵消一部分冲量(DJUMP 前乘 DJ_INERTIA)——
|
||||
// 全额抵消会让"满力一段跳到顶点、晚几帧接二段"的二段塌到接近 0(非常短),
|
||||
// 只抵消一半则顶点附近接二段保持满力,摔得快时才明显变弱
|
||||
this.vy = Math.max(this.vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; this.jumps = 2;
|
||||
this.djUp = this.vy < 0; // 二段上升段启用专属轻重力(与一段同款飘)
|
||||
sfx.djump();
|
||||
L.particles.spawn(this.cx, this.cy, 10,
|
||||
{ colors: ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: Math.PI * 2 });
|
||||
}
|
||||
}
|
||||
// 可变跳跃高度
|
||||
if (!Input.is('jump') && this.vy < -3) this.vy = -3;
|
||||
// 上升轻、下落重:分手感。二段上升段用更轻的 GRAV_DJ(初速小,同款飘)
|
||||
this.vy = Math.min(this.vy + (this.vy < 0 ? (this.djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL);
|
||||
if (this.djUp && this.vy >= 0) this.djUp = false; // 二段到顶,交回普通重力
|
||||
}
|
||||
|
||||
// ---- 站在移动平台上则跟随 ----
|
||||
if (this.mover && !this.mover.gone) {
|
||||
this.x += this.mover.dx;
|
||||
this.y += this.mover.dy;
|
||||
}
|
||||
|
||||
// ---- X 轴移动 ----
|
||||
const prevBottom = this.y + this.h;
|
||||
this.mover = null;
|
||||
let steps = Math.abs(this.vx);
|
||||
const dx = sgn(this.vx);
|
||||
while (steps > 0) {
|
||||
const m = Math.min(1, steps);
|
||||
if (!L.solidRect(this.x + dx * m, this.y, this.w, this.h)) { this.x += dx * m; steps -= m; }
|
||||
else break;
|
||||
}
|
||||
if (L.solidRect(this.x + dx, this.y, this.w, this.h) && Math.abs(this.vx) > 0) this.vx = 0;
|
||||
|
||||
// ---- Y 轴移动 ----
|
||||
const dropThrough = Input.is('down');
|
||||
steps = Math.abs(this.vy);
|
||||
const dy = sgn(this.vy);
|
||||
let hitGround = false;
|
||||
while (steps > 0) {
|
||||
const m = Math.min(1, steps);
|
||||
const ny = this.y + dy * m;
|
||||
const solidHere = L.solidRect(this.x, ny, this.w, this.h, {
|
||||
prevBottom, dropThrough,
|
||||
});
|
||||
if (!solidHere) { this.y = ny; steps -= m; }
|
||||
else {
|
||||
if (dy > 0) hitGround = true;
|
||||
this.vy = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 检查是否踩到移动平台
|
||||
for (const m of L.movers) {
|
||||
if (m.gone) continue;
|
||||
if (this.x + this.w > m.x && this.x < m.x + m.w) {
|
||||
if (this.y + this.h >= m.y - 2 && this.y + this.h <= m.y + m.h) {
|
||||
this.mover = m; hitGround = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wasAir = !this.grounded;
|
||||
this.grounded = hitGround || (this.vy === 0 && L.solidRect(this.x, this.y + 1, this.w, this.h));
|
||||
if (this.grounded) {
|
||||
this.jumps = 0; this.djUp = false;
|
||||
}
|
||||
|
||||
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
|
||||
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
|
||||
L.particles.spawn(this.cx - sgn(this.vx) * 5, this.y + this.h - 2, 2, {
|
||||
colors: ['#dff4ff', '#8fd8f0', '#fff'], speed: 1.1, life: 16, size: 2,
|
||||
dir: -Math.PI / 2, spread: 1.7, grav: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 边界 ----
|
||||
this.x = clamp(this.x, 0, L.pxW - this.w);
|
||||
if (this.y > L.pxH + 200) { this.kill(); return; }
|
||||
if (this.y < -300) { this.y = -300; this.vy = 0; }
|
||||
|
||||
// ---- 致死:同帧立刻复活,画面上不留任何死亡状态 ----
|
||||
if (L.deadlyRect(this.x, this.y, this.w, this.h)) { this.kill(); return; }
|
||||
|
||||
// ---- 射击 ----
|
||||
if (this.hasGun && Input.hit('shoot') && this.shootCool === 0 && L.bullets.length < 5) {
|
||||
this.shoot();
|
||||
}
|
||||
|
||||
// ---- 实体交互 ----
|
||||
this.touchEntities();
|
||||
|
||||
// ---- 消失方块踩踏 ----
|
||||
if (this.grounded) {
|
||||
const ty = Math.floor((this.y + this.h + 2) / TILE);
|
||||
for (let tx = Math.floor(this.x / TILE); tx <= Math.floor((this.x + this.w - 1) / TILE); tx++) {
|
||||
if (L.tiles[ty] && L.tiles[ty][tx] === T.VANISH && L.vanishState(tx, ty) === 0) {
|
||||
L.setVanish(tx, ty, 1);
|
||||
L.vanishT = L.vanishT || new Map();
|
||||
L.vanishT.set(ty * 1000 + tx, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
shoot() {
|
||||
const L = this.level;
|
||||
this.shootCool = 9;
|
||||
L.bullets.push({
|
||||
x: this.facing > 0 ? this.x + this.w - 2 : this.x - 6,
|
||||
y: this.y + 8, w: 10, h: 5,
|
||||
vx: 9 * this.facing, vy: 0, life: 130, dead: false,
|
||||
});
|
||||
sfx.shoot();
|
||||
L.particles.spawn(this.facing > 0 ? this.x + this.w : this.x, this.y + 10, 3,
|
||||
{ colors: ['#fff6a0'], speed: 1.5, life: 10, size: 2 });
|
||||
}
|
||||
|
||||
touchEntities() {
|
||||
const L = this.level;
|
||||
for (const e of L.entities) {
|
||||
if (e.dead) continue;
|
||||
// 存档点判定放宽 6px:跑得太快或从旁边擦过去也能吃到
|
||||
const hit = e.type === 'save'
|
||||
? overlap({ x: this.x - 6, y: this.y - 6, w: this.w + 12, h: this.h + 12 }, e)
|
||||
: overlap(this, e);
|
||||
if (!hit) continue;
|
||||
switch (e.type) {
|
||||
case 'save':
|
||||
if (!e.active) {
|
||||
e.active = true;
|
||||
this.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h, face: (this.level.faceIndex ?? 0) };
|
||||
this.saveIdx = e.no || 1;
|
||||
sfx.save();
|
||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
|
||||
{ colors: ['#b06bff', '#fff', '#e5d0ff'], speed: 2.6, life: 30 });
|
||||
// 假存档:立刻复活回上一个存档点,不播死亡动画
|
||||
if (e.fake) { L.showToast('假的。', 90); this.kill(); return; }
|
||||
}
|
||||
break;
|
||||
case 'goal':
|
||||
if (!this.clearing) { this.clearing = true; if (this.onClear) this.onClear(); }
|
||||
break;
|
||||
case 'gun':
|
||||
if (!e.taken) {
|
||||
e.taken = true; this.hasGun = true;
|
||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 12, { colors: ['#dfe0f0', '#fff'], speed: 2.2, life: 24 });
|
||||
L.showToast('获得手枪!按 X 射击', 120);
|
||||
sfx.save();
|
||||
}
|
||||
break;
|
||||
case 'apple':
|
||||
if (!e.taken) { e.taken = true; this.apples++; sfx.save(); }
|
||||
break;
|
||||
case 'jumpref':
|
||||
if (this.animT % 4 === 0) this.jumps = 0;
|
||||
break;
|
||||
case 'warp': {
|
||||
if (e.cool > 0) break;
|
||||
e.cool = 40;
|
||||
const to = e.to;
|
||||
// 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面
|
||||
if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') {
|
||||
if (this.onPortal) this.onPortal(e);
|
||||
break;
|
||||
}
|
||||
// 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对)
|
||||
const tgt = to === 'spawn' ? L.spawn
|
||||
: to && ('x' in to) ? { x: to.x * TILE, y: to.y * TILE }
|
||||
: (() => {
|
||||
const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
|
||||
return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null;
|
||||
})();
|
||||
if (tgt) {
|
||||
this.x = tgt.x; this.y = tgt.y;
|
||||
this.vx = 0; this.vy = 0;
|
||||
const partner = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
|
||||
if (partner) partner.cool = 40;
|
||||
sfx.warp();
|
||||
L.particles.spawn(this.cx, this.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sign':
|
||||
if (this.animT % 30 === 0) L.showToast(e.text, 90);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
// 死亡中:kid 已爆开,不画本体(粒子/冲击环由 Level 层渲染,重生后仍能播完)
|
||||
if (this.dead) return;
|
||||
S.drawKid(ctx, this, this.animT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 本地存档:通关记录、最佳成绩、自制关卡
|
||||
const KEY = 'iwbtc_fan_v1';
|
||||
|
||||
const blank = () => ({ cleared: {}, customs: [], audio: true });
|
||||
|
||||
export const Save = {
|
||||
data: blank(),
|
||||
|
||||
load() {
|
||||
try {
|
||||
const s = localStorage.getItem(KEY);
|
||||
if (s) this.data = Object.assign(blank(), JSON.parse(s));
|
||||
} catch (e) { this.data = blank(); }
|
||||
return this.data;
|
||||
},
|
||||
flush() {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(this.data)); } catch (e) { }
|
||||
},
|
||||
get(id) { return this.data.cleared[id] || null; },
|
||||
isCleared(id) { return !!this.data.cleared[id]; },
|
||||
/** 记录通关,返回是否刷新纪录 */
|
||||
clear(id, deaths, ms, apples) {
|
||||
const old = this.data.cleared[id];
|
||||
const rec = { deaths, time: ms, apples: apples || 0, at: Date.now() };
|
||||
let better = false;
|
||||
if (!old) { this.data.cleared[id] = rec; better = true; }
|
||||
else {
|
||||
const nd = Math.min(old.deaths, deaths);
|
||||
const nt = Math.min(old.time, ms);
|
||||
better = deaths < old.deaths || ms < old.time;
|
||||
this.data.cleared[id] = { ...old, deaths: nd, time: nt, apples: Math.max(old.apples || 0, apples || 0) };
|
||||
}
|
||||
this.flush();
|
||||
return better;
|
||||
},
|
||||
wipe() { this.data = blank(); this.flush(); },
|
||||
|
||||
customs() { return this.data.customs || []; },
|
||||
addCustom(lv) {
|
||||
this.data.customs = this.data.customs || [];
|
||||
this.data.customs.push(lv);
|
||||
this.flush();
|
||||
return lv;
|
||||
},
|
||||
/** 更新已保存的自制关卡(按 id 就地替换,不新增);id 不存在则追加 */
|
||||
updateCustom(lv) {
|
||||
this.data.customs = this.data.customs || [];
|
||||
const i = this.data.customs.findIndex(l => l.id === lv.id);
|
||||
if (i >= 0) this.data.customs[i] = lv;
|
||||
else this.data.customs.push(lv);
|
||||
this.flush();
|
||||
return lv;
|
||||
},
|
||||
removeCustom(id) {
|
||||
this.data.customs = (this.data.customs || []).filter(l => l.id !== id);
|
||||
this.flush();
|
||||
},
|
||||
setAudio(on) { this.data.audio = on; this.flush(); },
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
// 设置:用户名 + 设备相关自定义
|
||||
// · PC(电脑端)→ 键盘按键映射
|
||||
// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
|
||||
import { Save } from './save.js';
|
||||
import { applyBindings, defaultBindings, BIND } from './input.js';
|
||||
|
||||
// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
|
||||
// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
|
||||
const BUTTONS = [
|
||||
{ key: 'left', label: '◀', shape: 'round', def: { cx: 12, cy: 80, size: 62 } },
|
||||
{ key: 'right', label: '▶', shape: 'round', def: { cx: 24, cy: 80, size: 62 } },
|
||||
{ key: 'jump', label: '跳', shape: 'jump', def: { cx: 88, cy: 84, size: 94 } },
|
||||
{ key: 'shoot', label: '🔫', shape: 'item', label2: '射击', def: { cx: 79, cy: 72, size: 54 } },
|
||||
{ key: 'down', label: '▼', shape: 'item', label2: '下蹲', def: { cx: 90, cy: 72, size: 54 } },
|
||||
{ key: 'pause', label: '❚❚', shape: 'sm', def: { cx: 81, cy: 58, size: 42 } },
|
||||
{ key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } },
|
||||
];
|
||||
|
||||
// 设置界面里可重绑的键盘动作(标签 + action)
|
||||
const KEY_ACTIONS = [
|
||||
['left', '左移'], ['right', '右移'],
|
||||
['up', '上(上浮)'],
|
||||
['jump', '跳跃'], ['shoot', '射击'],
|
||||
['suicide', '自杀 / 重生'], ['restart', '整关重来'],
|
||||
['pause', '暂停'],
|
||||
];
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
const DEFAULT_TOUCH = {};
|
||||
BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); });
|
||||
const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH), keys: defaultBindings() });
|
||||
|
||||
// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
|
||||
let work = makeDefaults();
|
||||
let wrap = null;
|
||||
let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
|
||||
let listening = null; // 重绑监听:{act, idx}
|
||||
let conflict = null; // 冲突警告:{code, label, act}
|
||||
const els = {};
|
||||
let keysBox = null; // 键盘映射容器
|
||||
|
||||
/** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
|
||||
function meta() {
|
||||
if (!Save.data.settings) Save.data.settings = {};
|
||||
const s = Save.data.settings;
|
||||
if (typeof s.username !== 'string') s.username = '';
|
||||
if (!s.touch) s.touch = clone(DEFAULT_TOUCH);
|
||||
if (!s.keys) s.keys = defaultBindings();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
|
||||
function place(el, p) {
|
||||
el.style.left = p.cx + '%';
|
||||
el.style.top = p.cy + '%';
|
||||
el.style.width = p.size + 'px';
|
||||
el.style.height = p.size + 'px';
|
||||
}
|
||||
|
||||
/** 把当前存档布局应用到真正的 #touch 手柄按钮(游戏内生效) */
|
||||
function apply() {
|
||||
const t = meta().touch;
|
||||
for (const b of BUTTONS) {
|
||||
const el = document.querySelector(`#touch [data-touch="${b.key}"]`);
|
||||
if (!el) continue;
|
||||
const p = t[b.key];
|
||||
if (!p) continue;
|
||||
el.style.left = p.cx + '%';
|
||||
el.style.top = p.cy + '%';
|
||||
el.style.width = p.size + 'px';
|
||||
el.style.height = p.size + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/** 标题界面显示当前用户名 */
|
||||
function showUsername() {
|
||||
const el = document.getElementById('title-user');
|
||||
if (el) el.textContent = '玩家:' + (meta().username || '无名氏');
|
||||
}
|
||||
|
||||
/** 把键盘 code 转成可读标签 */
|
||||
function keyLabel(code) {
|
||||
const M = {
|
||||
Space: '空格', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||||
AltLeft: 'Alt', AltRight: 'Alt', Tab: 'Tab', Backspace: '⌫', Escape: 'Esc', Enter: 'Enter',
|
||||
ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
|
||||
MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: '菜单',
|
||||
};
|
||||
if (M[code]) return M[code];
|
||||
if (code.startsWith('Key')) return code.slice(3);
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
if (code.startsWith('Numpad')) return '小键' + code.slice(6);
|
||||
if (/^F\d{1,2}$/.test(code)) return code;
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 构建设置界面里的键盘映射行(点击按键后按新键重绑) */
|
||||
function renderKeys() {
|
||||
if (!keysBox) return;
|
||||
keysBox.innerHTML = '';
|
||||
// 冲突警告横幅(始终置于面板顶部,醒目)
|
||||
if (conflict) {
|
||||
const w = document.createElement('div');
|
||||
w.className = 'kwarb';
|
||||
w.textContent = `「${keyLabel(conflict.code)}」已被【${conflict.label}】占用,请换一个键(Esc 取消)`;
|
||||
keysBox.appendChild(w);
|
||||
}
|
||||
for (const [act, label] of KEY_ACTIONS) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'krow';
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'kact'; nm.textContent = label;
|
||||
const keys = document.createElement('div');
|
||||
keys.className = 'kkeys';
|
||||
(work.keys[act] || []).forEach((code, idx) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
const isListen = listening && listening.act === act && listening.idx === idx;
|
||||
chip.className = 'kchip' + (isListen ? ' listen' : '');
|
||||
chip.dataset.kact = act; chip.dataset.kidx = idx;
|
||||
chip.textContent = keyLabel(code);
|
||||
const rm = document.createElement('b');
|
||||
rm.className = 'krm'; rm.dataset.krm = act; rm.dataset.kidx = idx; rm.textContent = '×';
|
||||
chip.appendChild(rm);
|
||||
keys.appendChild(chip);
|
||||
});
|
||||
const add = document.createElement('button');
|
||||
add.type = 'button';
|
||||
add.className = 'kadd'; add.dataset.kadd = act; add.textContent = '+ 添加';
|
||||
keys.appendChild(add);
|
||||
if (listening && listening.act === act) {
|
||||
const tip = document.createElement('span');
|
||||
const isClash = conflict && conflict.act === act;
|
||||
tip.className = 'klisten' + (isClash ? ' bad' : '');
|
||||
tip.textContent = isClash ? '该键冲突,请换一个键(Esc 取消)' : '按下一个键…(Esc 取消)';
|
||||
keys.appendChild(tip);
|
||||
}
|
||||
row.appendChild(nm); row.appendChild(keys);
|
||||
keysBox.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测 code 是否已绑定到其它动作(返回冲突动作的标签,否则 null) */
|
||||
function conflictInfo(code, act, idx) {
|
||||
for (const [a, label] of KEY_ACTIONS) {
|
||||
const arr = work.keys[a] || [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] === code && !(a === act && i === idx)) return label;
|
||||
}
|
||||
}
|
||||
// 不可重绑的系统动作也算冲突(如「菜单」用的 KeyQ)
|
||||
if (BIND.menu && BIND.menu.includes(code)) return '菜单';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 重绑监听:捕获下一次按键并写入 work.keys(检测到冲突则拒绝并提示) */
|
||||
function onCaptureKey(e) {
|
||||
if (!listening) return;
|
||||
e.preventDefault();
|
||||
if (e.code === 'Escape') { listening = null; conflict = null; renderKeys(); return; }
|
||||
const { act, idx } = listening;
|
||||
const clash = conflictInfo(e.code, act, idx);
|
||||
if (clash) { // 该键已被其它动作占用 → 拒绝并提示
|
||||
conflict = { code: e.code, label: clash, act };
|
||||
renderKeys();
|
||||
return; // 停留在监听态,等待换键或 Esc
|
||||
}
|
||||
if (idx >= (work.keys[act] || []).length) (work.keys[act] = work.keys[act] || []).push(e.code);
|
||||
else work.keys[act][idx] = e.code;
|
||||
conflict = null;
|
||||
listening = null;
|
||||
renderKeys();
|
||||
}
|
||||
|
||||
/** 给键盘映射容器绑定点击委托(重绑 / 添加 / 删除) */
|
||||
let keysWired = false;
|
||||
function wireKeys() {
|
||||
if (!keysBox || keysWired) return;
|
||||
keysWired = true;
|
||||
keysBox.addEventListener('click', (e) => {
|
||||
const rm = e.target.closest('.krm');
|
||||
if (rm) {
|
||||
const a = rm.dataset.krm, i = +rm.dataset.kidx;
|
||||
work.keys[a].splice(i, 1);
|
||||
if (listening && listening.act === a) listening = null;
|
||||
renderKeys();
|
||||
return;
|
||||
}
|
||||
const chip = e.target.closest('.kchip');
|
||||
if (chip) { listening = { act: chip.dataset.kact, idx: +chip.dataset.kidx }; renderKeys(); return; }
|
||||
const add = e.target.closest('.kadd');
|
||||
if (add) { listening = { act: add.dataset.kadd, idx: 1e9 }; renderKeys(); return; }
|
||||
});
|
||||
}
|
||||
|
||||
export const Settings = {
|
||||
/** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
|
||||
init(isTouch) {
|
||||
pcMode = !isTouch;
|
||||
wrap = document.getElementById('touch-preview');
|
||||
keysBox = document.getElementById('set-keys');
|
||||
work = clone(meta());
|
||||
this.build();
|
||||
renderKeys();
|
||||
wireKeys();
|
||||
showUsername();
|
||||
apply();
|
||||
applyBindings(meta().keys);
|
||||
document.addEventListener('keydown', onCaptureKey);
|
||||
},
|
||||
|
||||
/** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
|
||||
build() {
|
||||
if (!wrap) return;
|
||||
wrap.querySelectorAll('.tc-btn').forEach(n => n.remove());
|
||||
for (const b of BUTTONS) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tc-btn tc-' + b.shape;
|
||||
el.innerHTML = `<span>${b.label}</span>` + (b.label2 ? `<span class="lbl">${b.label2}</span>` : '');
|
||||
const h = document.createElement('div');
|
||||
h.className = 'tc-rsz';
|
||||
el.appendChild(h);
|
||||
wrap.appendChild(el);
|
||||
els[b.key] = el;
|
||||
place(el, work.touch[b.key]);
|
||||
wire(el, h, b.key);
|
||||
}
|
||||
},
|
||||
|
||||
/** 打开设置界面:从存档同步工作副本与输入框,并按设备切换面板 */
|
||||
enter() {
|
||||
const s = meta();
|
||||
work = { username: s.username || '', touch: clone(s.touch), keys: clone(s.keys) };
|
||||
const inp = document.getElementById('set-username');
|
||||
if (inp) inp.value = work.username;
|
||||
listening = null;
|
||||
conflict = null;
|
||||
this.build();
|
||||
renderKeys();
|
||||
// 按设备显示对应面板:PC→键盘映射;移动端→手柄按钮
|
||||
const keysRow = document.getElementById('set-keys-row');
|
||||
const touchRow = document.getElementById('set-touch-row');
|
||||
if (keysRow) keysRow.classList.toggle('hidden', !pcMode);
|
||||
if (touchRow) touchRow.classList.toggle('hidden', pcMode);
|
||||
},
|
||||
|
||||
/** 确定:保存用户名 / 手柄布局 / 键位到存档,并应用,然后由调用方返回上一页 */
|
||||
confirm() {
|
||||
const s = meta();
|
||||
const inp = document.getElementById('set-username');
|
||||
work.username = (inp ? inp.value : '').trim().slice(0, 16);
|
||||
s.username = work.username;
|
||||
s.touch = clone(work.touch);
|
||||
s.keys = clone(work.keys);
|
||||
Save.flush();
|
||||
showUsername();
|
||||
apply();
|
||||
applyBindings(s.keys);
|
||||
},
|
||||
|
||||
/** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
|
||||
reset() {
|
||||
work = makeDefaults();
|
||||
listening = null;
|
||||
conflict = null;
|
||||
const inp = document.getElementById('set-username');
|
||||
if (inp) inp.value = '';
|
||||
this.build();
|
||||
renderKeys();
|
||||
},
|
||||
|
||||
/** 供调试 / 测试使用 */
|
||||
_state() { return work; },
|
||||
_els() { return els; },
|
||||
_pcMode() { return pcMode; },
|
||||
_conflict() { return conflict; },
|
||||
_listening() { return listening; },
|
||||
};
|
||||
|
||||
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
|
||||
function wire(el, handle, key) {
|
||||
let mode = null; // 'move' | 'resize'
|
||||
let rect = null; // 预览区矩形(按下时记录)
|
||||
let lastX = 0, lastY = 0; // 上一帧指针(相对位移用)
|
||||
let startSize = 0, startDist = 1, cx0 = 0, cy0 = 0; // 缩放用:按下时按钮中心与指针距离
|
||||
|
||||
// 按下:只记录基准,不重算绝对坐标 —— 避免任何「按下即跳角」
|
||||
const begin = (e, m) => {
|
||||
e.preventDefault();
|
||||
try { (m === 'resize' ? handle : el).setPointerCapture(e.pointerId); } catch (_) { }
|
||||
mode = m;
|
||||
rect = wrap.getBoundingClientRect();
|
||||
const W = rect.width || 1, H = rect.height || 1;
|
||||
const p = work.touch[key];
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
if (m === 'resize') {
|
||||
cx0 = rect.left + p.cx / 100 * W;
|
||||
cy0 = rect.top + p.cy / 100 * H;
|
||||
startSize = p.size;
|
||||
startDist = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
|
||||
}
|
||||
};
|
||||
|
||||
// 移动:用「相对上一帧的位移」累加,第一帧必然停在正确位置
|
||||
const drag = (e) => {
|
||||
if (!mode || !rect) return;
|
||||
const p = work.touch[key];
|
||||
if (mode === 'move') {
|
||||
const W = rect.width || 1, H = rect.height || 1;
|
||||
const dx = (e.clientX - lastX) / W * 100; // 像素位移 → 百分比
|
||||
const dy = (e.clientY - lastY) / H * 100;
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
p.cx = Math.max(3, Math.min(97, p.cx + dx));
|
||||
p.cy = Math.max(3, Math.min(97, p.cy + dy));
|
||||
} else {
|
||||
const d = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
|
||||
p.size = Math.max(24, Math.min(160, startSize * (d / startDist)));
|
||||
}
|
||||
place(el, p);
|
||||
};
|
||||
|
||||
const end = (e) => {
|
||||
if (mode) { try { (mode === 'resize' ? handle : el).releasePointerCapture(e.pointerId); } catch (_) { } }
|
||||
mode = null;
|
||||
};
|
||||
|
||||
el.addEventListener('pointerdown', (e) => { if (e.target === handle) return; begin(e, 'move'); });
|
||||
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); begin(e, 'resize'); });
|
||||
el.addEventListener('pointermove', drag);
|
||||
handle.addEventListener('pointermove', drag);
|
||||
el.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointerup', end);
|
||||
el.addEventListener('pointercancel', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// 程序化像素绘制(无外部图片资源)
|
||||
import { TILE, shade, hashNoise } from './util.js';
|
||||
|
||||
const C = {
|
||||
stone: '#6b6b7e', stoneDark: '#3a3a48', stoneLite: '#8f8fa6',
|
||||
grass: '#4f9e46', grassDark: '#2c5c2b',
|
||||
brick: '#a8543c', brickDark: '#632a1d',
|
||||
metal: '#5a6478', metalDark: '#333a4a',
|
||||
ice: '#8fd8f0', iceDark: '#4a90b8',
|
||||
water: '#2a6fd8',
|
||||
spike: '#cfdde9', spikeDark: '#a8bccf',
|
||||
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
|
||||
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050',
|
||||
};
|
||||
|
||||
/** 归一化的像素块绘制助手 */
|
||||
function px(ctx, x, y, w, h, col) { ctx.fillStyle = col; ctx.fillRect(x | 0, y | 0, w | 0, h | 0); }
|
||||
|
||||
// ---------------- 地形 ----------------
|
||||
export function drawBlock(ctx, x, y, variant = 0, lit = false) {
|
||||
const s = TILE;
|
||||
let base = C.stone, dark = C.stoneDark, lite = C.stoneLite;
|
||||
if (variant === 1) { base = C.grass; dark = C.grassDark; lite = '#7fd06a'; }
|
||||
if (variant === 2) { base = C.brick; dark = C.brickDark; lite = '#d1785c'; }
|
||||
if (variant === 3) { base = C.metal; dark = C.metalDark; lite = '#8a94ad'; }
|
||||
|
||||
px(ctx, x, y, s, s, dark);
|
||||
px(ctx, x + 1, y + 1, s - 2, s - 2, base);
|
||||
|
||||
// 纹理
|
||||
if (variant === 2) { // 砖
|
||||
px(ctx, x + 1, y + 8, s - 2, 1, dark);
|
||||
px(ctx, x + 1, y + 17, s - 2, 1, dark);
|
||||
px(ctx, x + 10, y + 1, 1, 7, dark);
|
||||
px(ctx, x + 20, y + 9, 1, 8, dark);
|
||||
px(ctx, x + 6, y + 18, 1, 7, dark);
|
||||
} else if (variant === 1) { // 草
|
||||
px(ctx, x + 1, y + 1, s - 2, 5, lite);
|
||||
for (let i = 0; i < 4; i++) px(ctx, x + 3 + i * 7, y + 2, 1, 4, dark);
|
||||
} else { // 石
|
||||
const n = hashNoise(x, y);
|
||||
px(ctx, x + 2, y + 2, 6, 2, lite);
|
||||
if (n > 0.5) px(ctx, x + 18, y + 12, 8, 2, dark);
|
||||
if (n < 0.35) px(ctx, x + 6, y + 22, 7, 2, dark);
|
||||
}
|
||||
// 高光/描边
|
||||
px(ctx, x, y, s, 1, lite);
|
||||
px(ctx, x, y, 1, s, lite);
|
||||
px(ctx, x, y + s - 1, s, 1, dark);
|
||||
px(ctx, x + s - 1, y, 1, s, dark);
|
||||
if (lit) { ctx.fillStyle = 'rgba(255,255,255,.09)'; ctx.fillRect(x, y, s, s); }
|
||||
}
|
||||
|
||||
export function drawIce(ctx, x, y) {
|
||||
const s = TILE;
|
||||
px(ctx, x, y, s, s, C.iceDark);
|
||||
px(ctx, x + 1, y + 1, s - 2, s - 2, C.ice);
|
||||
px(ctx, x + 2, y + 3, 10, 3, '#e6fbff');
|
||||
px(ctx, x + 18, y + 16, 8, 2, 'rgba(255,255,255,.7)');
|
||||
px(ctx, x, y, s, 1, '#e6fbff');
|
||||
px(ctx, x, y + s - 1, s, 1, C.iceDark);
|
||||
}
|
||||
|
||||
export function drawWater(ctx, x, y, t) {
|
||||
const s = TILE;
|
||||
px(ctx, x, y, s, s, 'rgba(40,110,215,.55)');
|
||||
ctx.fillStyle = 'rgba(120,200,255,.35)';
|
||||
const off = Math.sin(t / 260 + x * 0.09) * 3;
|
||||
ctx.fillRect(x, y + 4 + off, s, 3);
|
||||
ctx.fillRect(x, y + 16 - off, s, 2);
|
||||
}
|
||||
|
||||
export function drawOneWay(ctx, x, y) {
|
||||
const s = TILE;
|
||||
px(ctx, x, y, s, 6, C.metalDark);
|
||||
px(ctx, x, y, s, 3, '#9aa6bd');
|
||||
for (let i = 0; i < 8; i++) px(ctx, x + i * 4, y + 6, 2, 3, C.metalDark);
|
||||
}
|
||||
|
||||
// 单个三角形刺:尖端指向 dir,底边贴在相反一侧(与 level.js 的 spikeTri 顶点完全一致)。
|
||||
// 外观对齐经典 I wanna 素材:冰蓝白渐变的干净大三角,占满整格。
|
||||
export function drawSpike(ctx, x, y, dir, t) {
|
||||
const s = TILE;
|
||||
let ax, ay, bx, by, cx, cy;
|
||||
if (dir === 0) { // 朝上(地面上)
|
||||
ax = x + s / 2; ay = y; bx = x; by = y + s; cx = x + s; cy = y + s;
|
||||
} else if (dir === 1) { // 朝下(天花板)
|
||||
ax = x + s / 2; ay = y + s; bx = x; by = y; cx = x + s; cy = y;
|
||||
} else if (dir === 2) { // 朝左(贴右墙)
|
||||
ax = x; ay = y + s / 2; bx = x + s; by = y; cx = x + s; cy = y + s;
|
||||
} else { // 朝右(贴左墙)
|
||||
ax = x + s; ay = y + s / 2; bx = x; by = y; cx = x; cy = y + s;
|
||||
}
|
||||
// 冰蓝白渐变:尖端最亮,往底边过渡到淡蓝
|
||||
let g;
|
||||
if (dir === 0) { g = ctx.createLinearGradient(x, y, x, y + s); }
|
||||
else if (dir === 1) { g = ctx.createLinearGradient(x, y + s, x, y); }
|
||||
else if (dir === 2) { g = ctx.createLinearGradient(x, y, x + s, y); }
|
||||
else { g = ctx.createLinearGradient(x + s, y, x, y); }
|
||||
g.addColorStop(0, '#f4f9fd');
|
||||
g.addColorStop(0.55, '#cfdde9');
|
||||
g.addColorStop(1, '#a8bccf');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx, cy); ctx.closePath();
|
||||
ctx.fillStyle = g; ctx.fill();
|
||||
// 淡蓝描边(参考图的柔和轮廓)
|
||||
ctx.strokeStyle = 'rgba(150,175,200,.9)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
|
||||
/** 全致死方块(美味水果外观) */
|
||||
export function drawKiller(ctx, x, y, t) {
|
||||
const s = TILE;
|
||||
const bob = Math.sin(t / 300 + x) * 1.5;
|
||||
ctx.save();
|
||||
ctx.translate(x + s / 2, y + s / 2 + bob);
|
||||
// 苹果
|
||||
ctx.fillStyle = C.apple;
|
||||
ctx.beginPath(); ctx.arc(0, 2, 11, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ff7a90';
|
||||
ctx.beginPath(); ctx.arc(-4, -2, 4, 0, Math.PI * 2); ctx.fill();
|
||||
px(ctx, -1, -13, 2, 6, '#6b4423');
|
||||
ctx.fillStyle = '#3ddc84';
|
||||
ctx.beginPath(); ctx.ellipse(6, -11, 5, 2.6, -0.5, 0, Math.PI * 2); ctx.fill();
|
||||
// 恶作剧的眼睛
|
||||
px(ctx, -5, 0, 2, 2, '#000'); px(ctx, 3, 0, 2, 2, '#000');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawVanish(ctx, x, y, state, t) {
|
||||
const s = TILE;
|
||||
const alpha = state === 0 ? 1 : state === 1 ? (Math.sin(t / 60) > 0 ? 0.55 : 0.9) : 0.14;
|
||||
ctx.globalAlpha = alpha;
|
||||
drawBlock(ctx, x, y, 3);
|
||||
ctx.globalAlpha = 1;
|
||||
if (state !== 2) {
|
||||
ctx.strokeStyle = 'rgba(255,200,60,.55)'; ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x + 2.5, y + 2.5, s - 5, s - 5);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawJumpRef(ctx, x, y, t) {
|
||||
const s = TILE;
|
||||
const b = Math.sin(t / 200 + x) * 2;
|
||||
ctx.fillStyle = '#3ddc84';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + s / 2, y + 8 + b); ctx.lineTo(x + s - 6, y + s - 10 + b); ctx.lineTo(x + 6, y + s - 10 + b);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = '#0d5c30'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
|
||||
// ---------------- 玩家 ----------------
|
||||
export function drawKid(ctx, p, t) {
|
||||
const x = Math.round(p.x), y = Math.round(p.y);
|
||||
const w = p.w, h = p.h;
|
||||
const air = !p.grounded;
|
||||
// 不做任何 squash/stretch 形变:起跳与落地都保持原始体型,
|
||||
// 避免身体忽高忽低被感知为"上下晃动"(2026-10-23 用户两次反馈后彻底移除)
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
|
||||
const face = p.facing; // 1 右 -1 左
|
||||
// 身体(红衣)
|
||||
px(ctx, 2, 7, w - 4, 7, C.kid);
|
||||
px(ctx, 2, 7, w - 4, 1, shade(C.kid, 40));
|
||||
// 手臂
|
||||
if (air) { px(ctx, face > 0 ? w - 3 : 0, 5, 3, 5, C.skin); }
|
||||
else { px(ctx, face > 0 ? w - 3 : 0, 9, 3, 5, C.skin); }
|
||||
// 腿
|
||||
if (air) { px(ctx, 3, 14, 4, 4, C.pant); px(ctx, w - 7, 14, 4, 3, C.pant); }
|
||||
else { px(ctx, 2, 14, 4, 4, C.pant); px(ctx, w - 6, 14, 4, 4, C.pant); }
|
||||
// 头
|
||||
px(ctx, 1, 0, w - 2, 8, C.skin);
|
||||
px(ctx, 1, 0, w - 2, 3, C.hair); // 头发
|
||||
px(ctx, face > 0 ? w - 5 : 3, 1, 3, 2, C.hair);
|
||||
// 眼睛
|
||||
px(ctx, face > 0 ? w - 6 : 3, 4, 2, 2, '#101018');
|
||||
px(ctx, face > 0 ? w - 3 : 0, 4, 1, 2, '#101018');
|
||||
ctx.restore();
|
||||
|
||||
// 持枪
|
||||
if (p.hasGun && !p.dead) {
|
||||
const gx = face > 0 ? x + w - 1 : x - 7;
|
||||
const gy = y + 9;
|
||||
px(ctx, gx, gy, 8, 3, C.gun);
|
||||
px(ctx, face > 0 ? gx + 6 : gx, gy + 1, 3, 2, '#7a7a8c');
|
||||
px(ctx, gx + 1, gy + 3, 3, 3, '#6a6a7c');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 实体 ----------------
|
||||
export function drawSave(ctx, e, t) {
|
||||
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||||
const on = e.active;
|
||||
const pulse = 1 + Math.sin(t / 180) * 0.09;
|
||||
if (on) {
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = C.save;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, 15 * pulse, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
// 未激活:画一圈呼吸光环,别让玩家看不见它
|
||||
const r = 13 + Math.sin(t / 26) * 2.2;
|
||||
ctx.globalAlpha = 0.30 + Math.sin(t / 26) * 0.12;
|
||||
ctx.strokeStyle = '#8a6ab8';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
const col = on ? C.save : '#8f74b5';
|
||||
ctx.fillStyle = col;
|
||||
ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 4.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(cx + 4, cy + 1, 4.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(cx, cy + 5, 4.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(cx + 1, cy - 5, 4.2, 0, Math.PI * 2); ctx.fill();
|
||||
if (on) { ctx.fillStyle = '#e5d0ff'; ctx.beginPath(); ctx.arc(cx - 1, cy - 2, 1.6, 0, Math.PI * 2); ctx.fill(); }
|
||||
// 梗
|
||||
px(ctx, cx + 1, cy - 9, 2, 4, '#4a8a3a');
|
||||
}
|
||||
|
||||
export function drawGoal(ctx, e, t) {
|
||||
const x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
// 门
|
||||
px(ctx, x + 3, y + 2, w - 6, h - 2, '#2a1e3a');
|
||||
px(ctx, x + 5, y + 4, w - 10, h - 6, '#140d20');
|
||||
// 光柱
|
||||
const g = ctx.createLinearGradient(x, y + h, x, y);
|
||||
g.addColorStop(0, 'rgba(255,200,60,.55)');
|
||||
g.addColorStop(1, 'rgba(255,200,60,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(x + 5, y + 4, w - 10, h - 6);
|
||||
// 闪烁粒子
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = ((t / 700) + i / 3) % 1;
|
||||
px(ctx, x + 7 + ((i * 9) % (w - 12)), y + h - 4 - p * (h - 8), 2, 2, C.goal);
|
||||
}
|
||||
// 门框
|
||||
px(ctx, x + 3, y + 2, w - 6, 2, C.goal);
|
||||
px(ctx, x + 3, y + 2, 2, h - 2, '#c99a1e');
|
||||
px(ctx, x + w - 5, y + 2, 2, h - 2, '#c99a1e');
|
||||
}
|
||||
|
||||
export function drawGun(ctx, e, t) {
|
||||
const x = e.x, y = e.y + Math.sin(t / 250) * 2;
|
||||
px(ctx, x + 2, y + 6, 20, 7, '#4a4a5c'); // 枪身
|
||||
px(ctx, x + 2, y + 6, 20, 2, '#8a8aa0');
|
||||
px(ctx, x + 18, y + 8, 9, 3, C.gun); // 枪管
|
||||
px(ctx, x + 6, y + 13, 5, 8, '#3a3a4c'); // 握把
|
||||
px(ctx, x + 8, y + 19, 3, 3, '#2a2a3a');
|
||||
px(ctx, x + 24, y + 7, 2, 5, '#d0d0e0');
|
||||
}
|
||||
|
||||
export function drawApple(ctx, e, t) {
|
||||
const cx = e.x + e.w / 2, cy = e.y + e.h / 2 + Math.sin(t / 320 + e.x) * 1.5;
|
||||
ctx.fillStyle = C.apple;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ff8095';
|
||||
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, Math.PI * 2); ctx.fill();
|
||||
px(ctx, cx - 1, cy - 11, 2, 4, '#6b4423');
|
||||
ctx.fillStyle = '#3ddc84';
|
||||
ctx.beginPath(); ctx.ellipse(cx + 4, cy - 9, 4, 2, -0.5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
export function drawWarp(ctx, e, t) {
|
||||
const cx = e.x + e.w / 2, cy = e.y + e.h / 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = 14 - i * 4;
|
||||
const a = t / (300 + i * 120);
|
||||
ctx.strokeStyle = `rgba(${170 - i * 30},${90 + i * 40},255,${0.85 - i * 0.2})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, a, a + Math.PI * 1.4);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = 'rgba(120,60,200,.35)';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
/** 摄像头对象(镜头控制区标记) */
|
||||
export function drawCamera(ctx, e, t) {
|
||||
const x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
ctx.strokeStyle = `rgba(80,220,255,${0.35 + Math.sin(t / 300) * 0.15})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(x + 1, y + 1, w - 2, h - 2);
|
||||
ctx.setLineDash([]);
|
||||
// 镜头图标(放中央上方)
|
||||
const ix = x + w / 2 - 9, iy = y + 6;
|
||||
px(ctx, ix, iy + 3, 18, 11, '#1e1e2a');
|
||||
px(ctx, ix + 2, iy + 5, 14, 7, '#3a3a4c');
|
||||
ctx.fillStyle = '#7ae0ff';
|
||||
ctx.beginPath(); ctx.arc(ix + 9, iy + 8.5, 3.5, 0, Math.PI * 2); ctx.fill();
|
||||
px(ctx, ix + 12, iy, 5, 3, '#1e1e2a');
|
||||
px(ctx, ix + 13, iy - 1, 3, 2, '#ff4060');
|
||||
}
|
||||
|
||||
export function drawSign(ctx, e, t) {
|
||||
const x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
px(ctx, x + w / 2 - 1, y + h - 8, 3, 8, '#6b4423');
|
||||
px(ctx, x, y + 2, w, h - 10, '#c9a86b');
|
||||
px(ctx, x + 2, y + 4, w - 4, h - 14, '#e8d5a8');
|
||||
px(ctx, x + 4, y + 8, w - 8, 2, '#8a7550');
|
||||
px(ctx, x + 4, y + 13, w - 12, 2, '#8a7550');
|
||||
}
|
||||
|
||||
export function drawTurret(ctx, e, t) {
|
||||
const x = e.x, y = e.y, s = TILE;
|
||||
px(ctx, x + 2, y + 6, s - 4, s - 6, '#4a4a5e');
|
||||
px(ctx, x + 4, y + 8, s - 8, s - 12, '#6a6a80');
|
||||
const dirx = e.dir === 'left' ? -1 : e.dir === 'right' ? 1 : 0;
|
||||
const diry = e.dir === 'up' ? -1 : e.dir === 'down' ? 1 : 0;
|
||||
const cx = x + s / 2, cy = y + s / 2;
|
||||
px(ctx, cx + dirx * 8 - 3, cy + diry * 8 - 3, 7, 7, '#2a2a38');
|
||||
const blink = (Math.floor(t / 300) % 3) === 0;
|
||||
px(ctx, cx + dirx * 12 - 2, cy + diry * 12 - 2, 4, 4, blink ? '#ff4060' : '#8a2030');
|
||||
}
|
||||
|
||||
export function drawMovingPlatform(ctx, e) {
|
||||
const x = e.x, y = e.y, w = e.w, h = e.h;
|
||||
px(ctx, x, y, w, h, '#3a3a4c');
|
||||
px(ctx, x + 1, y + 1, w - 2, h - 2, '#6a6a84');
|
||||
px(ctx, x + 1, y + 1, w - 2, 2, '#9a9ab4');
|
||||
for (let i = 0; i < w; i += 8) px(ctx, x + i + 2, y + h - 4, 4, 2, '#3a3a4c');
|
||||
// 轨道虚线
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.10)';
|
||||
ctx.setLineDash([3, 5]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.ax + e.aw / 2, e.ay + e.ah / 2);
|
||||
ctx.lineTo(e.bx + e.aw / 2, e.by + e.ah / 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
/** 掉落刺(坑向:玩家靠近触发) */
|
||||
export function drawDropSpike(ctx, e, t) {
|
||||
const s = TILE;
|
||||
if (!e.falling) {
|
||||
ctx.globalAlpha = 0.85;
|
||||
px(ctx, e.x + 4, e.y + 4, s - 8, s - 8, '#4a4a5c');
|
||||
px(ctx, e.x + 6, e.y + 6, s - 12, s - 12, '#6a6a80');
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
drawSpike(ctx, e.x, e.y, 2, t);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawBullet(ctx, b) {
|
||||
px(ctx, b.x - 1, b.y - 1, b.w + 2, b.h + 2, 'rgba(255,240,120,.35)');
|
||||
px(ctx, b.x, b.y, b.w, b.h, '#fff6a0');
|
||||
px(ctx, b.x, b.y + b.h - 2, b.w, 2, '#ffb040');
|
||||
}
|
||||
|
||||
export function drawEBullet(ctx, b, t) {
|
||||
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
||||
ctx.fillStyle = b.col || '#ff5c7a';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 1, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(cx - 1.5, cy - 1.5, b.w / 4, 0, Math.PI * 2); ctx.fill();
|
||||
if (b.big) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.4)';
|
||||
ctx.beginPath(); ctx.arc(cx, cy, b.w / 2 + 4 + Math.sin(t / 90) * 2, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
export { C, px };
|
||||
@@ -0,0 +1,98 @@
|
||||
// 移动端虚拟手柄:左手方向、右手跳跃 + 道具(参考 IWBT Creator 的触屏布局)
|
||||
// 左下方:← → 移动;右下方:大跳键 + 射击/下蹲 道具键 + 暂停/重生
|
||||
import { setTouch, clearTouch } from './input.js';
|
||||
import { Settings } from './settings.js';
|
||||
|
||||
// 持续按住型动作(通过 action 状态机驱动)
|
||||
const HELD = ['left', 'right', 'jump', 'down'];
|
||||
|
||||
const api = {
|
||||
pause() {},
|
||||
suicide() {},
|
||||
shoot() {},
|
||||
};
|
||||
|
||||
let visible = false;
|
||||
let root = null;
|
||||
|
||||
function q(sel) { return document.querySelector(sel); }
|
||||
|
||||
function bind(btn) {
|
||||
const action = btn.dataset.touch;
|
||||
if (!action) return;
|
||||
let timer = null;
|
||||
|
||||
const press = (e) => {
|
||||
e.preventDefault();
|
||||
if (btn._pid != null) return; // 已在按住(多指防重入)
|
||||
btn._pid = e.pointerId;
|
||||
try { btn.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
btn.classList.add('on');
|
||||
|
||||
if (action === 'shoot') {
|
||||
// 长按连发(受玩家冷却与是否持枪限制)
|
||||
api.shoot();
|
||||
timer = setInterval(() => api.shoot(), 140);
|
||||
} else if (HELD.includes(action)) {
|
||||
setTouch(action, true);
|
||||
} else if (action === 'pause') {
|
||||
api.pause();
|
||||
} else if (action === 'suicide') {
|
||||
api.suicide();
|
||||
}
|
||||
};
|
||||
|
||||
const release = (e) => {
|
||||
if (btn._pid != null && e.pointerId !== btn._pid) return;
|
||||
btn._pid = null;
|
||||
try { btn.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
btn.classList.remove('on');
|
||||
if (action === 'shoot') {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
} else if (HELD.includes(action)) {
|
||||
setTouch(action, false);
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', press);
|
||||
btn.addEventListener('pointerup', release);
|
||||
btn.addEventListener('pointercancel', release);
|
||||
btn.addEventListener('lostpointercapture', release);
|
||||
btn.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
export const Touch = {
|
||||
init(handlers) {
|
||||
Object.assign(api, handlers || {});
|
||||
root = q('#touch');
|
||||
root.querySelectorAll('[data-touch]').forEach(bind);
|
||||
// 游戏进行中禁止页面滚动/回弹
|
||||
const stage = q('#stage');
|
||||
if (stage) {
|
||||
stage.addEventListener('touchmove', (e) => { if (visible) e.preventDefault(); }, { passive: false });
|
||||
stage.addEventListener('contextmenu', (e) => { if (visible) e.preventDefault(); });
|
||||
}
|
||||
},
|
||||
|
||||
show() {
|
||||
if (!root) root = q('#touch');
|
||||
if (!root) return;
|
||||
visible = true;
|
||||
Settings.apply(); // 应用用户自定义的手柄布局
|
||||
root.classList.remove('hidden');
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (!root) root = q('#touch');
|
||||
if (!root) return;
|
||||
visible = false;
|
||||
root.classList.add('hidden');
|
||||
clearTouch(); // 防止按键卡住
|
||||
},
|
||||
|
||||
/** 反映是否持有手枪:无枪时射击键置灰 */
|
||||
setGun(has) {
|
||||
const g = q('#tc-gun');
|
||||
if (g) g.classList.toggle('off', !has);
|
||||
},
|
||||
};
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
// DOM 界面:菜单 / 关卡选择 / HUD / 结算
|
||||
import { LEVELS, TYPE_LABEL, TYPE_CLASS } from './levels/index.js';
|
||||
import { Save } from './save.js';
|
||||
import { fmtTime } from './util.js';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||||
|
||||
const SCREENS = ['scr-title', 'scr-levels', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
|
||||
const TABS = [
|
||||
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
|
||||
];
|
||||
|
||||
export const UI = {
|
||||
H: {}, // handlers
|
||||
filter: 'all',
|
||||
manage: false, // 自制页「管理」态:开启后每张自制地图显示删除按钮
|
||||
cur: 'scr-title',
|
||||
toastTimer: 0,
|
||||
|
||||
init(handlers) {
|
||||
this.H = handlers;
|
||||
|
||||
// 按钮
|
||||
$$('[data-act]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const a = btn.dataset.act;
|
||||
if (this.H[a]) this.H[a]();
|
||||
});
|
||||
});
|
||||
|
||||
// 分类 tabs
|
||||
const tabs = $('#level-tabs');
|
||||
tabs.innerHTML = '';
|
||||
TABS.forEach(([k, label]) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'tab' + (k === 'all' ? ' on' : '');
|
||||
b.textContent = label;
|
||||
b.onclick = () => {
|
||||
this.filter = k;
|
||||
this.setManage(false); // 切换分类时退出管理模式
|
||||
$$('#level-tabs .tab').forEach(t => t.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
this.renderLevels();
|
||||
};
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
},
|
||||
|
||||
show(name) {
|
||||
SCREENS.forEach(s => $('#' + s).classList.toggle('hidden', s !== name));
|
||||
this.cur = name;
|
||||
$('#hud').classList.toggle('hidden', name !== null && name !== '');
|
||||
if (name === 'scr-levels') { this.setManage(false); this.renderLevels(); }
|
||||
},
|
||||
showGame() {
|
||||
SCREENS.forEach(s => $('#' + s).classList.add('hidden'));
|
||||
this.cur = '';
|
||||
$('#hud').classList.remove('hidden');
|
||||
},
|
||||
|
||||
renderLevels() {
|
||||
const grid = $('#level-grid');
|
||||
grid.innerHTML = '';
|
||||
const customs = Save.customs().map((l, i) => ({ ...l, index: 100 + i, no: i + 1 }));
|
||||
const all = [...LEVELS, ...customs];
|
||||
const list = all.filter(l => this.filter === 'all' ? true : l.type === this.filter);
|
||||
|
||||
// 「管理地图」按钮只在自制分类下显示
|
||||
this.updateManageBtn();
|
||||
|
||||
if (!list.length) {
|
||||
grid.innerHTML = '<div style="color:#666;padding:20px">这个分类下还没有关卡。去「自制关卡」造一个吧。</div>';
|
||||
return;
|
||||
}
|
||||
list.forEach(l => {
|
||||
const rec = Save.get(l.id);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lv-card' + (rec ? ' cleared' : '') + (this.manage && l.builtin === false ? ' manage' : '');
|
||||
const stars = '★'.repeat(l.diff || 1) + '☆'.repeat(Math.max(0, 5 - (l.diff || 1)));
|
||||
card.innerHTML = `
|
||||
<div class="idx">${l.builtin === false ? 'U' + (l.no || '') : String(l.index + 1).padStart(2, '0')}</div>
|
||||
<div class="nm">${escapeHtml(l.name || 'STAGE')}</div>
|
||||
<div class="meta">
|
||||
<span class="tag ${TYPE_CLASS[l.type] || 't-custom'}">${TYPE_LABEL[l.type] || '自制'}</span>
|
||||
<span style="color:#ffc63a">${stars}</span>
|
||||
</div>
|
||||
<div class="st">${rec ? `✔ 死亡 ${rec.deaths} · ${fmtTime(rec.time)}` : '未通关'}</div>
|
||||
${rec ? '<div class="chk">✔</div>' : ''}
|
||||
`;
|
||||
// 管理态下自制卡不再可进入,只保留删除按钮,避免误点开始
|
||||
if (!(this.manage && l.builtin === false)) card.onclick = () => this.H.play(l);
|
||||
// 管理态:自制地图显示删除按钮
|
||||
if (l.builtin === false && this.manage) {
|
||||
const del = document.createElement('button');
|
||||
del.className = 'lv-del';
|
||||
del.textContent = '✕';
|
||||
del.title = '删除这张地图';
|
||||
del.onclick = (e) => { e.stopPropagation(); this.delCustom(l); };
|
||||
card.appendChild(del);
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
},
|
||||
|
||||
/** 刷新「管理地图」按钮:仅在自制分类可见;管理态文案变「完成」并高亮 */
|
||||
updateManageBtn() {
|
||||
const mb = $('#manage-btn');
|
||||
if (!mb) return;
|
||||
mb.classList.toggle('hidden', this.filter !== 'custom');
|
||||
mb.textContent = this.manage ? '完成' : '管理地图';
|
||||
mb.classList.toggle('on', this.manage);
|
||||
},
|
||||
|
||||
/** 切换自制页的管理态(显示/隐藏删除按钮) */
|
||||
setManage(on) {
|
||||
// 仅自制分类可进入管理态;非自制分类的开启请求按退出处理(按钮不可见,须自动复位)
|
||||
if (on && this.filter !== 'custom') on = false;
|
||||
this.manage = !!on;
|
||||
this.updateManageBtn();
|
||||
this.renderLevels();
|
||||
},
|
||||
|
||||
/** 删除一张自制地图(从关卡选择界面管理) */
|
||||
delCustom(l) {
|
||||
if (typeof confirm === 'function' && !confirm('删除地图「' + (l.name || 'MY MAP') + '」?此操作不可恢复')) return;
|
||||
Save.removeCustom(l.id);
|
||||
this.renderLevels();
|
||||
},
|
||||
|
||||
hud({ name, deaths, ms, apples, totalApples, hasGun, save, totalSaves }) {
|
||||
$('#hud-level').textContent = name || '';
|
||||
$('#hud-deaths').textContent = 'DEATH ' + deaths;
|
||||
$('#hud-time').textContent = fmtTime(ms);
|
||||
const ap = $('#hud-apples');
|
||||
if (totalApples > 0) { ap.style.display = ''; ap.textContent = `苹果 ${apples}/${totalApples}`; }
|
||||
else ap.style.display = 'none';
|
||||
const sv = $('#hud-save');
|
||||
if (totalSaves > 0) {
|
||||
sv.style.display = '';
|
||||
sv.textContent = save > 0 ? `存档 ${save}/${totalSaves}` : `存档 0/${totalSaves}`;
|
||||
sv.classList.toggle('on', save > 0);
|
||||
} else sv.style.display = 'none';
|
||||
},
|
||||
|
||||
bossBar(boss) {
|
||||
const bar = $('#boss-bar');
|
||||
if (!boss) { bar.classList.add('hidden'); return; }
|
||||
bar.classList.remove('hidden');
|
||||
$('#boss-name').textContent = boss.name;
|
||||
$('#boss-fill').style.width = Math.max(0, (boss.hp / boss.maxHp) * 100) + '%';
|
||||
},
|
||||
|
||||
toast(text) {
|
||||
const el = $('#toast');
|
||||
if (!text) { el.classList.remove('show'); return; }
|
||||
if (el.textContent !== text) el.textContent = text;
|
||||
el.classList.add('show');
|
||||
},
|
||||
|
||||
result({ name, ms, deaths, apples, totalApples, better, hasNext }) {
|
||||
$('#r-name').textContent = name;
|
||||
$('#r-time').textContent = fmtTime(ms);
|
||||
$('#r-death').textContent = deaths;
|
||||
$('#r-apple').textContent = totalApples ? `${apples}/${totalApples}` : '—';
|
||||
$('#r-best').textContent = better ? '★ 新纪录!' : '';
|
||||
$('#scr-result').querySelector('[data-act="next"]').style.display = hasNext ? '' : 'none';
|
||||
this.show('scr-result');
|
||||
},
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 通用工具
|
||||
export const TILE = 32;
|
||||
export const ROOM_W = 800;
|
||||
export const ROOM_H = 608;
|
||||
export const GRID_W = ROOM_W / TILE; // 25
|
||||
export const GRID_H = ROOM_H / TILE; // 19
|
||||
|
||||
export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||||
export const lerp = (a, b, t) => a + (b - a) * t;
|
||||
export const sgn = v => (v < 0 ? -1 : v > 0 ? 1 : 0);
|
||||
export const rand = (a, b) => a + Math.random() * (b - a);
|
||||
export const randInt = (a, b) => Math.floor(a + Math.random() * (b - a + 1));
|
||||
export const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
export function overlap(a, b) {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
export function pointIn(px, py, r) {
|
||||
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
|
||||
}
|
||||
|
||||
/** 浅色/深色化 hex 颜色,n>0 变亮 n<0 变暗 */
|
||||
export function shade(hex, n) {
|
||||
const h = hex.replace('#', '');
|
||||
const v = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
|
||||
let r = (v >> 16) & 255, g = (v >> 8) & 255, b = v & 255;
|
||||
r = clamp(r + n, 0, 255); g = clamp(g + n, 0, 255); b = clamp(b + n, 0, 255);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
/** 时间毫秒 -> m:ss.cc */
|
||||
export function fmtTime(ms) {
|
||||
const t = Math.max(0, ms);
|
||||
const m = Math.floor(t / 60000);
|
||||
const s = Math.floor((t % 60000) / 1000);
|
||||
const c = Math.floor((t % 1000) / 10);
|
||||
return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 平滑的确定性伪随机(用于装饰,不影响帧同步) */
|
||||
export function hashNoise(x, y) {
|
||||
let h = x * 374761393 + y * 668265263;
|
||||
h = (h ^ (h >> 13)) * 1274126177;
|
||||
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"levels":[{"id":"t1","name":"FIRST STEPS","type":"jump","diff":1,"bg":"jump","width":36,"hint":"← → 移动,Shift / Z / 空格跳跃。刺碰到就死,R 键重来。","messages":["← → 移动 Shift 跳跃 R 重来"],"grid":["####################################","#..................................#","#..................................#","#..................................#","#..................................#","#..................................#","#..................................#","#.............................X....#","#............................#####.#","#..................................#","#.........................#####....#","#..................................#","#.....................#####........#","#..................................#","#.................#####............#","#..................................#","#............#####.................#","#.S.T..^^^............G............#","####################################"],"index":0,"builtin":true},{"id":"t2","name":"DOUBLE JUMP","type":"jump","diff":1,"bg":"custom","width":40,"hint":"空中再按一次跳跃=二段跳。5 格宽的刺坑只能靠二段跳过去。","messages":["空中再按一次 Shift = 二段跳"],"grid":["########################################","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#......................................#","#..................X...................#","#.................####.................#","#......................................#","#.........................####.........#","#......................................#","#.................####.................#","#......................................#","#.........................####.........#","#......................................#","#.................####.................#","#.S.T......^^^^^...G...................#","########################################"],"index":1,"builtin":true},{"id":"j1","name":"SPIKE ALLEY","cn":"刺巷","type":"jump","diff":2,"hint":"针刺入门:刺毯单跳节奏、钻石缝、矮洞撞头跳、竖井、对刺门。","width":104,"grid":["########################################################################################################","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#................................................................................vvvvvvvvvvvvvvv.......#","#......................................................................................................#","#......................................................................................................#","#......................................................................................................#","#..............................................................................#.......................#","#..............................................................................#..G^.^^.^..>^<G^.......#","#.........................................................................#....###################.....#","#.........................................................................###..#.......................#","#.........................................................................#....#.......................#","#.........................................................................>....#...................>...#","#.........vvvvvvvvvvvvvvvvv.vvvvvvvvvvvvvvvv..............................#..###...................>...#","#.........................................................................#....<...................>..<#","#...........................................#############..........#......#....#...................>..<#","#..............v.....v........v...v...v......................#.....#.......##..#...................>..<#","#.........................................................#..#..#..#..#........#......................<#","#..S....G.^^^..^..^..^..^.^.G.^...^...^..^^..G.>.<..>^<...#..#..#..#..#.G......#....................X..#","#########################################################..............#################################","#........................................................^^^^^^^^^^^^^^................................#","#......................................................................................................#","########Line truncated
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>关卡地图预览 · IWBT C 同人</title>
|
||||
<style>
|
||||
:root { --bg:#0e1018; --panel:#171b26; --line:#2a3040; --fg:#dfe3ee; --dim:#8b93a7; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.6 ui-sans-serif,system-ui,"PingFang SC","Microsoft YaHei",sans-serif; }
|
||||
header { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
|
||||
h1 { font-size:15px; font-weight:500; margin:0 14px 0 0; }
|
||||
select, button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:5px 10px; font:inherit; }
|
||||
.stat { color:var(--dim); font-size:13px; }
|
||||
.stat b { color:var(--fg); font-weight:500; }
|
||||
main { padding:14px 18px 30px; overflow:auto; }
|
||||
canvas { display:block; background:var(--panel); border:1px solid var(--line); border-radius:6px; image-rendering:pixelated; }
|
||||
.legend { margin-top:12px; display:flex; gap:18px; flex-wrap:wrap; color:var(--dim); font-size:13px; }
|
||||
.legend i { display:inline-block; width:12px; height:12px; border-radius:2px; vertical-align:-2px; margin-right:5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>关卡地图预览</h1>
|
||||
<select id="sel"></select>
|
||||
<span class="stat" id="stat"></span>
|
||||
</header>
|
||||
<main><canvas id="cv"></canvas></main>
|
||||
<div class="legend">
|
||||
<span><i style="background:#3a4056"></i>实心块</span>
|
||||
<span><i style="background:#5b7fa6"></i>冰面</span>
|
||||
<span><i style="background:#6b5b8a"></i>单向台</span>
|
||||
<span><i style="background:#2d5a7a"></i>水</span>
|
||||
<span><i style="background:#e04b6a"></i>四向刺</span>
|
||||
<span><i style="background:#8b3ad0"></i>即死块</span>
|
||||
<span><i style="background:#3ad07a"></i>出生点</span>
|
||||
<span><i style="background:#4bc8ff"></i>存档点</span>
|
||||
<span><i style="background:#ffd24b"></i>终点 / 苹果</span>
|
||||
<span><i style="background:#c48bff"></i>手枪·跳台·炮台·BOSS·掉落刺</span>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { LEVELS } from './js/levels/index.js';
|
||||
|
||||
const COLORS = {
|
||||
'#': '#3a4056', '@': '#3a4056', '=': '#3a4056', 'M': '#4a4f63',
|
||||
'%': '#5b7fa6', '-': '#6b5b8a', '~': '#2d5a7a', '*': '#8b3ad0', 'V': '#4a4f63',
|
||||
'^': '#e04b6a', 'v': '#e04b6a', '<': '#e04b6a', '>': '#e04b6a',
|
||||
S: '#3ad07a', G: '#4bc8ff', X: '#ffd24b', F: '#ffd24b',
|
||||
P: '#c48bff', J: '#c48bff', O: '#c48bff', B: '#c48bff', D: '#c48bff',
|
||||
W: '#c48bff', T: '#8b93a7', C: '#8b93a7', '!': '#8b93a7',
|
||||
};
|
||||
|
||||
const sel = document.getElementById('sel');
|
||||
const stat = document.getElementById('stat');
|
||||
const cv = document.getElementById('cv');
|
||||
const ctx = cv.getContext('2d');
|
||||
|
||||
LEVELS.forEach((l, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = i;
|
||||
o.textContent = `${l.id} ${l.name} ${l.cn || ''}`;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
|
||||
function draw(d) {
|
||||
const rows = d.grid, h = rows.length, w = d.width;
|
||||
const s = Math.max(4, Math.min(16, Math.floor(Math.min(1500 / w, 820 / h))));
|
||||
cv.width = w * s; cv.height = h * s;
|
||||
cv.style.width = cv.width + 'px';
|
||||
cv.style.height = cv.height + 'px';
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
let spike = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const line = rows[y] || '';
|
||||
for (let x = 0; x < w; x++) {
|
||||
const ch = line[x] || '.';
|
||||
if (ch === '.') continue;
|
||||
if ('^v<>'.includes(ch)) spike++;
|
||||
const c = COLORS[ch];
|
||||
if (!c) continue;
|
||||
ctx.fillStyle = c;
|
||||
if ('^v<>'.includes(ch)) { // 刺画成三角形,方向和判定一致
|
||||
ctx.beginPath();
|
||||
const px = x * s, py = y * s;
|
||||
if (ch === '^') { ctx.moveTo(px + s / 2, py + 2); ctx.lineTo(px + s - 2, py + s - 1); ctx.lineTo(px + 2, py + s - 1); }
|
||||
if (ch === 'v') { ctx.moveTo(px + 2, py + 1); ctx.lineTo(px + s - 2, py + 1); ctx.lineTo(px + s / 2, py + s - 2); }
|
||||
if (ch === '<') { ctx.moveTo(px + 1, py + s / 2); ctx.lineTo(px + s - 1, py + 2); ctx.lineTo(px + s - 1, py + s - 2); }
|
||||
if (ch === '>') { ctx.moveTo(px + 1, py + 2); ctx.lineTo(px + s - 1, py + 1); ctx.lineTo(px + 1, py + s - 2); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
} else if ('SGXFPJODBW'.includes(ch)) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x * s + s / 2, y * s + s / 2, s * 0.3, 0, 7);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(x * s, y * s, s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
const total = w * h;
|
||||
const saves = rows.join('').split('').filter(c => c === 'G').length;
|
||||
stat.innerHTML = `<b>${d.name}</b> ${d.cn || ''} · ${w}×${h} 格 · 类型 ${d.type} · 难度 ${d.diff} · 刺 ${spike} 格(${(spike / total * 100).toFixed(1)}%)· 存档点 ${saves}`;
|
||||
}
|
||||
|
||||
sel.addEventListener('change', () => draw(LEVELS[+sel.value]));
|
||||
draw(LEVELS[0]);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{ "type": "module", "private": true }
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>刺外观预览(对齐参考图)</title>
|
||||
<style>
|
||||
body { background:#20242c; color:#dde; font-family:monospace; text-align:center; padding:16px; }
|
||||
canvas { image-rendering:pixelated; border:1px solid #445; max-width:100%; }
|
||||
p { font-size:13px; color:#99a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>单三角形刺 · 冰蓝白渐变(游戏真实代码渲染)</h3>
|
||||
<canvas id="cv" width="768" height="384"></canvas>
|
||||
<p>上排:地面刺(朝上) · 中间:天花板刺(朝下) · 左右:贴墙刺(朝左/朝右)</p>
|
||||
<script type="module">
|
||||
import { drawSpike, drawBlock } from './js/sprites.js';
|
||||
const TILE = 32;
|
||||
const cv = document.getElementById('cv');
|
||||
const ctx = cv.getContext('2d');
|
||||
// 地面与天花板(参照截图的格子地面)
|
||||
for (let x = 0; x < 24; x++) {
|
||||
drawBlock(ctx, x * TILE, 8 * TILE, 0); // 下半地面
|
||||
drawBlock(ctx, x * TILE, 0, 0); // 顶部天花板
|
||||
}
|
||||
// 地面刺一排(朝上)
|
||||
for (let x = 3; x <= 9; x++) drawSpike(ctx, x * TILE, 7 * TILE, 0, 0);
|
||||
// 天花板刺(朝下)
|
||||
for (let x = 13; x <= 17; x++) drawSpike(ctx, x * TILE, TILE, 1, 0);
|
||||
// 贴墙刺:左墙(朝右)+ 右墙(朝左)
|
||||
drawSpike(ctx, 0, 5 * TILE, 3, 0);
|
||||
drawSpike(ctx, 23 * TILE, 5 * TILE, 2, 0);
|
||||
// 中间孤刺
|
||||
drawSpike(ctx, 11 * TILE, 7 * TILE, 0, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
// 机器人试玩:真实物理下向右推进,看存档点是否会被自然触发
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const id = process.argv[2] || 'j1';
|
||||
const d = LEVELS.find(l => l.id === id);
|
||||
const L = new Level(d);
|
||||
const P = new Player(L);
|
||||
let deaths = 0;
|
||||
P.onDeath = () => deaths++;
|
||||
|
||||
down('ArrowRight');
|
||||
let jumpPhase = 0;
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
// 简易 AI:遇到前方障碍/刺就跳,滞空一会儿再补二段跳
|
||||
const ahead = L.solidRect(P.x + 3, P.y, P.w, P.h) || L.deadlyRect(P.x + 20, P.y + P.h - 6, 12, 8);
|
||||
const gap = !L.solidRect(P.x + 6, P.y + P.h + 2, P.w, 4);
|
||||
if (P.grounded && (ahead || gap)) { down('ShiftLeft'); jumpPhase = i; }
|
||||
if (!P.grounded && i - jumpPhase === 12) { up('ShiftLeft'); down('ShiftLeft'); }
|
||||
if (i - jumpPhase === 30) up('ShiftLeft');
|
||||
L.update(1, P, null);
|
||||
for (const b of L.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (P.savePoint) break;
|
||||
}
|
||||
const saves = L.entities.filter(e => e.type === 'save');
|
||||
console.log(`${id}: 存档点 ${saves.filter(s => s.active).length}/${saves.length} 被激活, x=${P.x.toFixed(0)}, 死亡=${deaths}`);
|
||||
saves.forEach(s => console.log(` (${s.tx},${s.ty}) active=${s.active}`));
|
||||
console.log(` savePoint=${P.savePoint ? `(${P.savePoint.x.toFixed(0)},${P.savePoint.y.toFixed(0)})` : 'null'} spawn=(${L.spawn.x},${L.spawn.y})`);
|
||||
@@ -0,0 +1,55 @@
|
||||
// 关卡数据格式校验
|
||||
import tutorial from '../js/levels/tutorial.js';
|
||||
import jump from '../js/levels/jump.js';
|
||||
import endurance from '../js/levels/endurance.js';
|
||||
import boss from '../js/levels/boss.js';
|
||||
import troll from '../js/levels/troll.js';
|
||||
|
||||
const KNOWN = new Set([...'#@=M%~-*V^v<>SGFXPJWTOCBD! .']);
|
||||
const ALL = [...tutorial, ...jump, ...endurance, ...boss, ...troll];
|
||||
let bad = 0;
|
||||
|
||||
console.log('=== 关卡格式 ===');
|
||||
for (const l of ALL) {
|
||||
const w = l.width;
|
||||
const h = l.grid.length;
|
||||
const tag = `${l.id} ${l.name}`;
|
||||
if (h < 19) { console.log(`! ${tag}: 高度 ${h} 行(建议 >=19,画布高 19 格)`); bad++; }
|
||||
if (h > 60) { console.log(`! ${tag}: 高度 ${h} 行过大`); bad++; }
|
||||
l.grid.forEach((row, y) => {
|
||||
if (row.length !== w) { console.log(`! ${tag} row${y}: len=${row.length} 应为 ${w}`); bad++; }
|
||||
for (const ch of row) if (!KNOWN.has(ch)) { console.log(`! ${tag} row${y}: 未知字符 '${ch}'`); bad++; }
|
||||
});
|
||||
const all = l.grid.join('');
|
||||
const cnt = c => all.split(c).length - 1;
|
||||
const s = cnt('S'), x = cnt('X'), b = cnt('B');
|
||||
if (s !== 1) { console.log(`! ${tag}: S=${s}(必须且只能有一个出生点)`); bad++; }
|
||||
if (l.type === 'boss') { if (b < 1) { console.log(`! ${tag}: BOSS关缺 B`); bad++; } }
|
||||
else if (x < 1) { console.log(`! ${tag}: X=${x}(缺终点)`); bad++; }
|
||||
|
||||
if (l.turrets) {
|
||||
const o = cnt('O');
|
||||
if (o !== l.turrets.length) { console.log(`! ${tag}: O=${o} 但 turrets=${l.turrets.length}`); bad++; }
|
||||
}
|
||||
if (l.camzones) {
|
||||
const c = cnt('C');
|
||||
if (c > l.camzones.length) { console.log(`! ${tag}: C=${c} camzones=${l.camzones.length}`); bad++; }
|
||||
}
|
||||
if (l.warps) {
|
||||
const ww = cnt('W');
|
||||
if (ww !== l.warps.length) { console.log(`! ${tag}: W=${ww} 但 warps=${l.warps.length}`); bad++; }
|
||||
}
|
||||
if (l.bosses) {
|
||||
if (b !== l.bosses.length) { console.log(`! ${tag}: B=${b} 但 bosses=${l.bosses.length}`); bad++; }
|
||||
}
|
||||
if (l.messages) {
|
||||
const t = cnt('T');
|
||||
if (t > l.messages.length) { console.log(`! ${tag}: T=${t} 但 messages=${l.messages.length}`); bad++; }
|
||||
}
|
||||
const g = cnt('G'), f = cnt('F');
|
||||
console.log(` ${tag.padEnd(24)} ${String(w).padStart(3)}x${String(h).padStart(2)} S=${s} X=${x} G=${g} F=${f} B=${b} ${l.type}`);
|
||||
}
|
||||
|
||||
console.log(`\n共 ${ALL.length} 关`);
|
||||
console.log(bad ? `❌ 发现 ${bad} 个问题` : '✅ 全部关卡格式 OK');
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,272 @@
|
||||
// 回归测试:死亡流程(2026-10-24 新语义,推翻旧的"即时复活、无动画"需求)
|
||||
// 新需求(用户原话):
|
||||
// ① "当角色死亡后不要立马重生,需要用户手动按重生键重生"
|
||||
// ② "死亡时除了原先的红叉,还要附加一个小的死亡动画"
|
||||
// 本测试覆盖:死亡定格等待手动重生 / 死亡动画要素(粒子+震动+冲击环圆心)/
|
||||
// 手动 respawn 回存档点 / 连续死亡 / 红叉常驻与去重 / 掉坑死亡位置 clamp
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 20, H = 8, TILE = 32;
|
||||
// row1 有存档点 'G'(col1);row2 放两枚上刺(col5/col10);row3 实心地板
|
||||
const grid = [
|
||||
'#'.repeat(W),
|
||||
'#' + 'G' + '.'.repeat(W - 3) + '#',
|
||||
'#' + '.'.repeat(4) + '^' + '.'.repeat(4) + '^' + '.'.repeat(9) + '#',
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
'#'.repeat(W),
|
||||
];
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
const ok = m => console.log(' ✓ ' + m);
|
||||
const step = (L, P, n) => { for (let i = 0; i < n; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); } };
|
||||
|
||||
function fresh() {
|
||||
const L = new Level({ id: 'dt', name: 'DT', type: 'jump', width: W, grid });
|
||||
const P = new Player(L);
|
||||
let deaths = 0;
|
||||
P.onDeath = () => deaths++;
|
||||
step(L, P, 12);
|
||||
return { L, P, deaths: () => deaths };
|
||||
}
|
||||
|
||||
// 1) 踩刺:当帧进入死亡状态并定格(不自动复活),动画要素齐全
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
const sx = L.spawn.x;
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11; // 直接压在上刺上
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (P.alive || !P.dead) err('踩刺后没有进入死亡定格状态');
|
||||
else ok('踩刺后当帧死亡并定格(等待手动重生)');
|
||||
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
|
||||
else ok('死亡计数 = 1(正常累加)');
|
||||
if (P.vx !== 0 || P.vy !== 0) err('死亡时速度未清零');
|
||||
if (!(L.particles.list.length > 0)) err('死亡没有触发粒子爆发(小死亡动画缺失)');
|
||||
else ok(`死亡动画:粒子爆发 ${L.particles.list.length} 个`);
|
||||
if (!(L.shake > 0)) err('死亡没有触发屏幕轻震');
|
||||
else ok(`死亡动画:屏幕轻震 shake=${L.shake.toFixed(1)}`);
|
||||
if (Math.abs(P.deathX - (5 * TILE + 5.5)) > 12 || Math.abs(P.deathY - (2 * TILE + 21.5)) > 12)
|
||||
err(`死亡动画圆心偏离死亡点 (${P.deathX.toFixed(0)},${P.deathY.toFixed(0)})`);
|
||||
else ok('死亡动画圆心 = 死亡点');
|
||||
if (!L.deathMarks.length) err('死亡未在死亡地点留下 IWBT 血迹效果');
|
||||
else {
|
||||
const dm = L.deathMarks[0];
|
||||
const ex = 5 * TILE + 5.5, ey = 2 * TILE + 21.5;
|
||||
if (Math.abs(dm.x - ex) > 30 || Math.abs(dm.y - ey) > 30) err(`血迹偏离死亡点 (${dm.x.toFixed(0)},${dm.y.toFixed(0)})`);
|
||||
else ok('在死亡地点留下红色死亡叉(X)');
|
||||
}
|
||||
if (P.x !== 5 * TILE) err('死亡后位置被改动(应定格在原处)');
|
||||
else ok('死亡后定格在死亡位置');
|
||||
}
|
||||
|
||||
// 2) 死亡后 60 帧:仍然 dead、不自动重生、输入完全无效
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
const x0 = P.x;
|
||||
down('ArrowRight'); down('ShiftLeft');
|
||||
step(L, P, 60);
|
||||
up('ArrowRight'); up('ShiftLeft');
|
||||
if (P.alive) err('死亡 60 帧后自动复活了(违反"手动重生"需求)');
|
||||
else ok('死亡后 60 帧仍定格等待手动重生');
|
||||
if (deaths() !== 1) err(`死亡计数被重复触发(${deaths()})`);
|
||||
if (P.deadT < 60) err(`deadT 未随死亡时间递增(${P.deadT})`);
|
||||
else ok(`deadT 随时间递增(${P.deadT})`);
|
||||
if (P.x !== x0) err('死亡后按方向键仍能移动');
|
||||
else ok('死亡中输入无效(方向/跳跃均不响应)');
|
||||
}
|
||||
|
||||
// 3) 手动 respawn:回到最近存档点、justRespawned 置位、立即可操作
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
// 先碰存档点(col1)
|
||||
const s = L.entities.find(e => e.type === 'save');
|
||||
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
|
||||
step(L, P, 3);
|
||||
const cp = P.savePoint;
|
||||
if (!cp) { err('存档点未激活(前置失败)'); }
|
||||
else {
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.dead) err('踩刺未死');
|
||||
P.respawn(); // ← 模拟玩家按 R 手动重生
|
||||
if (!P.alive || P.dead) err('手动 respawn 后未复活');
|
||||
else ok('手动 respawn 成功复活');
|
||||
if (Math.abs(P.x - cp.x) > 1) err(`复活位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
|
||||
else ok('复活位置 = 最近存档点');
|
||||
if (P.justRespawned !== true) err('未标记 justRespawned,镜头会慢慢飘回去');
|
||||
else ok('justRespawned 已置位(镜头瞬移)');
|
||||
const x0 = P.x;
|
||||
down('ArrowRight');
|
||||
step(L, P, 6);
|
||||
up('ArrowRight');
|
||||
if (P.x <= x0) err('重生后当帧无法操作');
|
||||
else ok(`重生后立即可操作(6 帧走了 ${(P.x - x0).toFixed(1)}px)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 连续死亡 5 次:每次都须手动重生才能继续,计数准确
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
for (let k = 0; k < 5; k++) {
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.dead) { err(`第 ${k + 1} 次踩刺未死`); break; }
|
||||
step(L, P, 10); // 死亡中跑 10 帧:不许自动复活
|
||||
if (P.alive) { err(`第 ${k + 1} 次死亡后自动复活`); break; }
|
||||
P.respawn(); // 手动重生后才有下一次
|
||||
}
|
||||
if (deaths() !== 5) err(`连续 5 次死亡计数为 ${deaths()}`);
|
||||
else ok('连续 5 次死亡均等待手动重生,计数 = 5');
|
||||
}
|
||||
|
||||
// 5) 红叉常驻保留;整关重开清空
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
P.respawn();
|
||||
step(L, P, 30);
|
||||
if (!L.deathMarks.length) err('重生后红叉被清掉了(应常驻保留)');
|
||||
else ok(`死亡红叉常驻保留(${L.deathMarks.length} 处,重生 30 帧后仍在)`);
|
||||
L.reset(true);
|
||||
if (L.deathMarks.length) err(`整关重开后血迹应清空,实际 ${L.deathMarks.length}`);
|
||||
else ok('整关重开(reset(true))后血迹清空');
|
||||
}
|
||||
|
||||
// 6) 多次死亡:只显示最后一次死亡的红叉(不累积)
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
P.respawn();
|
||||
P.x = 10 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (L.deathMarks.length !== 1) err(`多次死亡应只显示最后一次血迹,实际 ${L.deathMarks.length} 处`);
|
||||
else {
|
||||
const dm = L.deathMarks[0];
|
||||
if (Math.abs(dm.x - (10 * TILE + 5.5)) > 30) err(`应只保留最后一次(col10)血迹,实际在 x=${dm.x.toFixed(0)}`);
|
||||
else ok('多次死亡只保留上一次死亡地点的血迹');
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 掉坑死亡:动画圆心/红叉 clamp 到关卡内(玩家已在屏幕外也要可见)
|
||||
{
|
||||
const { L, P } = fresh();
|
||||
P.y = L.pxH + 300; // 深坑底(屏幕外)
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (!P.dead) err('掉坑未触发死亡');
|
||||
else ok('掉坑触发死亡并定格');
|
||||
if (P.deathY > L.pxH - 10) err(`掉坑死亡的动画圆心未 clamp 到关卡内(deathY=${P.deathY.toFixed(0)} > ${L.pxH - 10})`);
|
||||
else ok(`掉坑死亡圆心 clamp 到关卡底缘(deathY=${P.deathY.toFixed(0)})`);
|
||||
if (P.deathX < 10 || P.deathX > L.pxW - 10) err('动画圆心 X 未 clamp 到关卡内');
|
||||
if (!L.deathMarks.length) err('掉坑死亡未留红叉');
|
||||
else if (L.deathMarks[0].y > L.pxH) err('掉坑死亡的红叉画到了关卡外');
|
||||
else ok('掉坑死亡的红叉在关卡内可见');
|
||||
}
|
||||
|
||||
// 8) 死亡动画渲染冒烟:冲击环(Level 层)+ 粒子逐帧 draw 不抛错(canvas 用代理桩)
|
||||
{
|
||||
const { L } = fresh();
|
||||
const ctx2d = new Proxy({ canvas: { width: 800, height: 608 } },
|
||||
{ get(t, k) { return k in t ? t[k] : (() => { }); }, set(t, k, v) { t[k] = v; return true; } });
|
||||
try {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
L.drawDeathRing(ctx2d, { x: 100, y: 100, t: i });
|
||||
L.particles.spawn(100, 100, 4, { life: 20 });
|
||||
L.particles.draw(ctx2d);
|
||||
}
|
||||
ok('死亡动画(冲击环+粒子)20 帧渲染无异常');
|
||||
} catch (e) { err(`死亡动画渲染抛错:${e.message}`); }
|
||||
}
|
||||
|
||||
// 9) 主动按重生键(main.js suicide):当帧重生 + 演出留在死亡位置播完(不清粒子/震屏)
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
const s = L.entities.find(e => e.type === 'save');
|
||||
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
|
||||
step(L, P, 3);
|
||||
const cp = P.savePoint;
|
||||
if (!cp) { err('存档点未激活(前置失败)'); }
|
||||
else {
|
||||
P.x = 12 * TILE; P.y = 2 * TILE - P.h; // 走到右侧空地(col5/col10 有刺,避开)
|
||||
step(L, P, 5);
|
||||
const marks0 = L.deathMarks.length;
|
||||
const burstX = P.cx, burstY = P.cy;
|
||||
P.deathBurst(); // ← 模拟活着按 R 的演出部分(不进死亡状态)
|
||||
if (P.dead || !P.alive) err('死亡演出把玩家带进了死亡状态(应纯演出)');
|
||||
else ok('死亡演出为纯演出(不进死亡状态,可当帧重生)');
|
||||
if (deaths() !== 0) err(`主动重生键不应计死亡,实际 ${deaths()}`);
|
||||
else ok('主动重生键不计死亡数');
|
||||
if (L.deathMarks.length !== marks0) err('主动重生键不应留红叉');
|
||||
else ok('主动重生键不留红叉');
|
||||
if (!(L.particles.list.length > 0) || !(L.shake > 0)) err('主动重生键的死亡动画缺失(粒子/震屏)');
|
||||
else ok('主动重生键播死亡动画(粒子+轻震+冲击环)');
|
||||
if (!L.deathFX.length) err('冲击环未记录到关卡层');
|
||||
else if (Math.abs(L.deathFX[0].x - burstX) > 12) err('冲击环圆心偏离死亡点');
|
||||
else ok('冲击环记录在死亡位置(Level 层)');
|
||||
// 当帧重生:不等动画
|
||||
P.respawn();
|
||||
if (!P.alive || P.dead) err('未当帧重生');
|
||||
else ok('当帧重生(不等待动画)');
|
||||
if (Math.abs(P.x - cp.x) > 1) err(`重生位置不对:x=${P.x.toFixed(1)} 期望 ${cp.x.toFixed(1)}`);
|
||||
else ok('重生回最近存档点');
|
||||
if (P.justRespawned !== true) err('未标记 justRespawned,镜头不会瞬移');
|
||||
// 动画在死亡位置继续播完:粒子未被清、冲击环在推进
|
||||
if (!(L.particles.list.length > 0)) err('重生把演出粒子清掉了(应留在死亡位置播完)');
|
||||
else ok('重生不清场:粒子留在死亡位置继续播');
|
||||
step(L, P, 5);
|
||||
if (!L.deathFX.length) err('冲击环被提前清掉了');
|
||||
step(L, P, 12); // 累计 17 帧 > 14
|
||||
if (L.deathFX.length) err('冲击环 14 帧后未移除');
|
||||
else ok('冲击环 14 帧扩散淡出后自动移除');
|
||||
step(L, P, 25); // 粒子 life 34,累计 42 帧应自然播完
|
||||
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length} 个)`);
|
||||
else ok('粒子自然播完(34 帧寿命)');
|
||||
if (!(L.shake < 0.6)) err(`震屏未自然衰减(${L.shake.toFixed(2)})`);
|
||||
else ok('震屏自然衰减归零');
|
||||
}
|
||||
}
|
||||
|
||||
// 10) 真死亡(被动)后玩家很快按 R:特效不被重生清场,留在死亡位置自然播完
|
||||
{
|
||||
const { L, P, deaths } = fresh();
|
||||
P.x = 5 * TILE; P.y = 2 * TILE + 11;
|
||||
L.update(1, P, null); P.update(1); Input.endFrame(); // 踩刺真死
|
||||
if (!P.dead) err('踩刺未死(前置失败)');
|
||||
else {
|
||||
step(L, P, 3); // 玩家只等了 3 帧就按 R
|
||||
if (deaths() !== 1) err(`死亡计数应为 1,实际 ${deaths()}`);
|
||||
P.respawn();
|
||||
if (!P.alive || P.dead) err('快速重生失败');
|
||||
else ok('真死亡后 3 帧即按 R:成功重生');
|
||||
if (!(L.particles.list.length > 0)) err('快速重生把死亡粒子清掉了(应留在死亡位置播完)');
|
||||
else ok('快速重生不清场:粒子留在死亡位置继续播');
|
||||
if (!L.deathFX.length) err('快速重生把冲击环清掉了');
|
||||
else ok('快速重生不清场:冲击环继续扩散');
|
||||
if (!(L.shake > 0)) err('快速重生把震屏清掉了');
|
||||
else ok('快速重生不清场:震屏继续衰减');
|
||||
step(L, P, 50); // 粒子 34 / 环 14 / 震屏衰减,全部应自然结束
|
||||
if (L.particles.list.length) err(`粒子未自然播完(残余 ${L.particles.list.length})`);
|
||||
else if (L.deathFX.length) err('冲击环未自然播完');
|
||||
else if (L.shake > 0.01) err(`震屏未自然衰减(${L.shake.toFixed(3)})`);
|
||||
else ok('特效全部自然播完(粒子 34 帧 / 冲击环 14 帧 / 震屏衰减)');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 死亡流程全部通过:定格等待手动重生 + 小红死亡动画 + 主动重生键当帧重生');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,95 @@
|
||||
// 二段跳时机回归(tools/dj-test.mjs)
|
||||
// 历史 bug 链:
|
||||
// ① 全额抵消(旧 `vy = max(vy,0) - DJUMP`):满力一段到顶点后晚几帧接二段,二段塌到接近 0(用户报"二段非常短")
|
||||
// ② 部分抵消(DJ_INERTIA 0.5→0.35):仍有"好沉+高度短"——起跳初速被下落速度从 6.7 吃到 3~4,
|
||||
// 起跳那一下没劲才是"沉"的根源,与上升重力无关
|
||||
// ③ 现行(DJ_INERTIA = 0,同 IWBT 原版):任何时机接二段都满力起跳,初速恒 -DJUMP
|
||||
// 本测试用真实引擎测量:不同时机接二段时,二段自身的提升高度(djRise)。
|
||||
// 通过标准:
|
||||
// · 任意时机接二段都 ≈ 满力(≈60px ≈ 1.9 格)——INERTIA=0 语义,曲线应基本持平
|
||||
// · 满力一段 ≈ 84px(≈2.6 格);顶点接二段总高 ≈ 4.5 格
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
// 与 jumpdist.mjs 同一块平地:row17 是实心平台
|
||||
const flat = {
|
||||
id: 'test', name: 'T', type: 'jump', width: 44,
|
||||
grid: (() => {
|
||||
const rows = [];
|
||||
rows.push('#'.repeat(44));
|
||||
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
|
||||
rows.push('#' + '#'.repeat(42) + '#');
|
||||
rows.push('#'.repeat(44));
|
||||
return rows;
|
||||
})(),
|
||||
};
|
||||
|
||||
// 满力一段跳(起跳后一直按住);第 djFrame 帧松键、djFrame+1 帧接二段(之后保持按住)
|
||||
// 返回:firstRise 一段顶点提升 / djRise 二段自身提升(接二段位置→二段后最高点)/ total 二段顶点离平台高度
|
||||
function measure(djFrame) {
|
||||
const L = new Level(flat);
|
||||
const P = new Player(L);
|
||||
P.x = 8 * 32; P.y = 17 * 32 - P.h; // 站在 row17 实心平台上
|
||||
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
|
||||
const y0 = P.y;
|
||||
key('keydown', 'ShiftLeft'); // 起跳并按住 = 满力一段
|
||||
let apex1 = y0, apex2 = y0, djDone = false, vyAtDj = null, yAtDj = y0;
|
||||
for (let i = 0; i < 240; i++) {
|
||||
const f = i + 1;
|
||||
if (f === djFrame) key('keyup', 'ShiftLeft');
|
||||
if (f === djFrame + 1) { key('keydown', 'ShiftLeft'); vyAtDj = P.vy; yAtDj = P.y; }
|
||||
L.update(1, P, null);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (djFrame && f === djFrame + 1 && P.jumps === 2) djDone = true;
|
||||
if (djDone) apex2 = Math.min(apex2, P.y);
|
||||
else apex1 = Math.min(apex1, P.y);
|
||||
if (i > 2 && P.grounded) break;
|
||||
if (P.dead) break;
|
||||
}
|
||||
key('keyup', 'ShiftLeft');
|
||||
Input.endFrame();
|
||||
return { firstRise: y0 - apex1, djRise: djDone ? yAtDj - apex2 : 0, total: y0 - apex2, vyAtDj, djDone };
|
||||
}
|
||||
|
||||
// 满力一段顶点≈第 27 帧;落地≈第 50 帧。取 27~46 帧覆盖"顶点 → 落地前"整个二段窗口
|
||||
const FRAMES = [27, 30, 34, 38, 42, 46];
|
||||
const T = 32;
|
||||
let fail = 0;
|
||||
const rows = [];
|
||||
console.log('满力一段跳后,不同时机接二段:二段自身提升高度(真实引擎实测)');
|
||||
console.log(' 时机 接二段时vy 一段顶点 二段自身提升 二段顶点离平台');
|
||||
for (const f of FRAMES) {
|
||||
const r = measure(f);
|
||||
rows.push({ f, ...r });
|
||||
const dj = r.djDone
|
||||
? `${r.djRise.toFixed(1).padStart(6)}px = ${(r.djRise / T).toFixed(2)} 格`
|
||||
: ' —— 已落地,二段窗口已过 ——';
|
||||
console.log(` 第${String(f).padStart(2)}帧 ${(r.vyAtDj ?? 0).toFixed(2).padStart(5)} ${r.firstRise.toFixed(1).padStart(6)}px ${dj} ${(r.total / T).toFixed(2)} 格`);
|
||||
}
|
||||
|
||||
const at = f => rows.find(r => r.f === f);
|
||||
const need = (cond, msg) => { if (!cond) { fail++; console.log(' ❌ ' + msg); } };
|
||||
|
||||
for (const r of rows) need(r.djDone, `第 ${r.f} 帧应还在二段窗口内(未落地)`);
|
||||
const apex = at(27);
|
||||
need(apex.firstRise > 79 && apex.firstRise < 90, `满力一段高度应 ≈84px(2.6格),实测 ${apex.firstRise.toFixed(1)}`);
|
||||
need(apex.djRise > 52 && apex.djRise < 68, `顶点接二段自身提升应 ≈59px(1.85格),实测 ${apex.djRise.toFixed(1)}`);
|
||||
need(apex.total > 138 && apex.total < 150, `顶点接二段总高应 ≈4.5格,实测 ${(apex.total / T).toFixed(2)}`);
|
||||
// INERTIA=0:任何时机接二段都满力起跳,曲线应基本持平(与顶点差异 ≤3px)
|
||||
for (const r of rows) {
|
||||
if (!r.f) continue;
|
||||
need(r.djRise >= 55, `第 ${r.f} 帧接二段自身提升应 ≈满力(≥55px),实测 ${r.djRise.toFixed(1)}`);
|
||||
need(Math.abs(r.djRise - apex.djRise) <= 3, `第 ${r.f} 帧与顶点接二段差异应 ≤3px(INERTIA=0 曲线持平),实测差 ${Math.abs(r.djRise - apex.djRise).toFixed(1)}`);
|
||||
}
|
||||
need(at(46).djRise >= 55, `即使摔得快二段也不应衰减(INERTIA=0),实测 ${at(46).djRise.toFixed(1)}`);
|
||||
|
||||
if (fail) { console.log(`\n❌ dj-test 失败 ${fail} 项`); process.exit(1); }
|
||||
console.log('\n✅ dj-test 通过:任意时机接二段均满力起跳(INERTIA=0),顶点总高 ≈4.5 格');
|
||||
@@ -0,0 +1,94 @@
|
||||
// DOM 环境冒烟:mock 浏览器 API,跑通入口 main.js 的初始化 + 若干帧
|
||||
const noop = () => { };
|
||||
const els = new Map();
|
||||
|
||||
function mkEl(id = '') {
|
||||
const el = {
|
||||
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
|
||||
style: {}, dataset: {}, children: [], width: 800, height: 608,
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
addEventListener: noop, removeEventListener: noop, appendChild: noop,
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d,
|
||||
focus: noop, click: noop,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, {
|
||||
get(t, k) { return k in t ? t[k] : noop; },
|
||||
set(t, k, v) { t[k] = v; return true; },
|
||||
});
|
||||
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: addL, removeEventListener: noop,
|
||||
AudioContext: null, webkitAudioContext: null,
|
||||
innerWidth: 1280, innerHeight: 800,
|
||||
location: globalThis.location, navigator: globalThis.navigator,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = {
|
||||
_d: {},
|
||||
getItem(k) { return this._d[k] ?? null; },
|
||||
setItem(k, v) { this._d[k] = String(v); },
|
||||
removeItem(k) { delete this._d[k]; },
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
|
||||
querySelector(s) { return mkEl(s); },
|
||||
querySelectorAll() { return []; },
|
||||
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||
addEventListener: addL,
|
||||
body: mkEl('body'),
|
||||
};
|
||||
let frames = 0;
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
if (frames++ > 12) return 0;
|
||||
setTimeout(() => fn(performance.now() + frames * 16.7), 0);
|
||||
return frames;
|
||||
};
|
||||
|
||||
try {
|
||||
await import('../js/main.js');
|
||||
console.log('✅ main.js 初始化成功');
|
||||
} catch (e) {
|
||||
console.log('❌ main.js 初始化失败: ' + e.message);
|
||||
console.log(e.stack.split('\n').slice(0, 5).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 等几帧
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const api = globalThis.window.__IW;
|
||||
console.log('✅ 已跑 ' + frames + ' 帧,状态=' + (api ? api.G.state : '?'));
|
||||
if (api) {
|
||||
// 逐个关卡启动一次,确认不炸
|
||||
let bad = 0;
|
||||
for (const lv of LEVELS) {
|
||||
try {
|
||||
api.startLevel(lv, lv.index ?? -1);
|
||||
for (let i = 0; i < 90; i++) { /* 由 rAF 驱动,这里只验证构造 */ }
|
||||
if (!api.G.level || !api.G.player) throw new Error('未创建关卡');
|
||||
} catch (e) { console.log(' ✗ ' + lv.id + ': ' + e.message); bad++; }
|
||||
}
|
||||
console.log(bad ? `❌ ${bad} 个关卡启动失败` : `✅ 全部 ${LEVELS.length} 关均可启动`);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,136 @@
|
||||
// 编辑器交互回归:调色板无「查看属性」工具;无论手上拿什么方块(橡皮除外),
|
||||
// 点击画布上任意方块都选中该格并在属性面板显示其属性(不覆盖已有方块)。
|
||||
const noop = () => { };
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
|
||||
// ---------- 最小假 DOM(addEventListener 收句柄,children 真实入列)----------
|
||||
const els = new Map();
|
||||
function mkEl(id = '') {
|
||||
const e = {
|
||||
id, tagName: 'DIV', value: '', _html: '', textContent: '',
|
||||
style: {}, dataset: {}, children: [],
|
||||
width: 800, height: 608,
|
||||
classList: {
|
||||
_s: new Set(),
|
||||
add(...c) { c.forEach(x => this._s.add(x)); },
|
||||
remove(...c) { c.forEach(x => this._s.delete(x)); },
|
||||
toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
|
||||
contains(c) { return this._s.has(c); },
|
||||
},
|
||||
_h: {},
|
||||
addEventListener(t, fn) { (this._h[t] = this._h[t] || []).push(fn); },
|
||||
removeEventListener: noop,
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d, focus: noop, click: noop,
|
||||
preventDefault: noop,
|
||||
};
|
||||
Object.defineProperty(e, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
set(v) { this._html = String(v); this.children = []; },
|
||||
});
|
||||
return e;
|
||||
}
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, {
|
||||
get(t, k) { return k in t ? t[k] : noop; },
|
||||
set(t, k, v) { t[k] = v; return true; },
|
||||
});
|
||||
const getEl = s => { if (!els.has(s)) els.set(s, mkEl(s)); return els.get(s); };
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
|
||||
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = noop;
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.document = {
|
||||
getElementById: getEl, querySelector: getEl, querySelectorAll: () => [],
|
||||
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||
addEventListener: noop, body: mkEl('body'),
|
||||
};
|
||||
|
||||
const { Editor, PALETTE } = await import('../js/editor.js');
|
||||
|
||||
// 画布坐标 → 格子:pos() 用 clientX/Y(scroll=0,缩放 1:1),格心 (tx*32+16, ty*32+16)
|
||||
const ev = (tx, ty, button = 0) => ({ clientX: tx * 32 + 16, clientY: ty * 32 + 16, button, preventDefault: noop });
|
||||
const clickTile = (tx, ty, button = 0) => {
|
||||
for (const fn of getEl('#ed-canvas')._h.mousedown) fn(ev(tx, ty, button));
|
||||
};
|
||||
// renderProps 先写面板 innerHTML(标题),再把每个属性字段 appendChild 成子行
|
||||
const propsHtml = () => {
|
||||
const box = getEl('#ed-props');
|
||||
return box.innerHTML + box.children.map(c => c.innerHTML || '').join('');
|
||||
};
|
||||
|
||||
console.log('=== 调色板不再有「查看属性」工具 ===');
|
||||
ok(!PALETTE.some(p => p[0] === '?'), "PALETTE 中已无 '?'(查看属性)");
|
||||
ok(!PALETTE.some(p => p[1] === '查看属性'), 'PALETTE 中已无「查看属性」条目');
|
||||
Editor.init({ startLevel: noop });
|
||||
const palLabels = getEl('#palette').children.map(c => c.textContent || '');
|
||||
ok(palLabels.length === PALETTE.length, `调色板渲染 ${PALETTE.length} 项(无工具项),实际 ${palLabels.length}`);
|
||||
ok(!palLabels.some(t => t.includes('查看属性')), '调色板 UI 无「查看属性」按钮');
|
||||
|
||||
console.log('=== 打开地图,构造一块带属性的摄像头 C ===');
|
||||
Editor.open();
|
||||
const f = Editor.curFace();
|
||||
f.grid[5][7] = 'C'; // 摄像头:SCHEMA 里有 4 个可编辑属性
|
||||
f.props[5][7] = { w: 4, h: 4, mode: 'zoom', zoom: 1.5 };
|
||||
|
||||
console.log('=== 手上拿刺(^),点击已有摄像头 → 只看属性,不覆盖 ===');
|
||||
Editor.sel = '^';
|
||||
clickTile(7, 5);
|
||||
ok(f.grid[5][7] === 'C', '格子内容未被覆盖(仍是 C)');
|
||||
ok(Editor.selTile && Editor.selTile.tx === 7 && Editor.selTile.ty === 5, 'selTile 指向被点方块 (7,5)');
|
||||
const html1 = propsHtml();
|
||||
ok(html1.includes('区域宽') && html1.includes('镜头模式'), '属性面板显示该方块的属性(区域宽/镜头模式)');
|
||||
|
||||
console.log('=== 手上拿方块(#),点击同一格 → 同样只查看,不覆盖 ===');
|
||||
Editor.sel = '#';
|
||||
clickTile(7, 5);
|
||||
ok(f.grid[5][7] === 'C', '拿 # 点击也不覆盖已有方块');
|
||||
ok(propsHtml().includes('区域宽'), '属性面板仍显示 C 的属性');
|
||||
|
||||
console.log('=== 手上拿炮台(O),点击空格子 → 放置并显示新方块属性 ===');
|
||||
Editor.sel = 'O';
|
||||
clickTile(3, 9);
|
||||
ok(f.grid[9][3] === 'O', '空格子正常放置 O');
|
||||
ok(propsHtml().includes('发射间隔'), '放置后属性面板显示 O 的属性');
|
||||
|
||||
console.log('=== 提示牌 T 的文本属性可随点击查看 ===');
|
||||
f.grid[9][4] = 'T'; f.props[9][4] = { msg: '小心刺' };
|
||||
Editor.sel = '%'; // 拿着冰面点提示牌
|
||||
clickTile(4, 9);
|
||||
ok(f.grid[9][4] === 'T' && propsHtml().includes('提示文字'), '非相关工具点击 T 也能查看文字属性');
|
||||
|
||||
console.log('=== 橡皮擦例外:选中橡皮点击 → 擦除 ===');
|
||||
Editor.sel = '.';
|
||||
clickTile(4, 9);
|
||||
ok(f.grid[9][4] === '.', '橡皮擦点击后 T 被擦除');
|
||||
Editor.sel = '^'; // 恢复非橡皮工具
|
||||
clickTile(7, 5); // 顺手确认 C 还在
|
||||
|
||||
console.log('=== 右键擦除与工具无关 ===');
|
||||
Editor.sel = '#';
|
||||
clickTile(3, 9, 2); // button=2 右键
|
||||
ok(f.grid[9][3] === '.', '右键擦除不受手上工具影响');
|
||||
|
||||
console.log('=== 属性面板空态提示仍存在 ===');
|
||||
Editor.show(); // 重置 selTile
|
||||
ok(propsHtml().includes('点击画布上的方块'), '未选中时显示引导文案');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 编辑器点击查看属性 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,97 @@
|
||||
// 编辑器所见即所得测试:刺以三角形绘制 + 每种方块都能正常渲染
|
||||
const noop = () => { };
|
||||
const els = new Map();
|
||||
|
||||
// 记录关键绘制调用的计数器(证明刺用三角形图元、方块用真实纹理)
|
||||
const C = { lineTo: 0, fillRect: 0, beginPath: 0, moveTo: 0, arc: 0 };
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, {
|
||||
get(t, k) { return k in t ? t[k] : noop; },
|
||||
set(t, k, v) { t[k] = v; return true; },
|
||||
});
|
||||
// 装上计数器
|
||||
ctx2d.lineTo = () => C.lineTo++;
|
||||
ctx2d.fillRect = () => C.fillRect++;
|
||||
ctx2d.beginPath = () => C.beginPath++;
|
||||
ctx2d.moveTo = () => C.moveTo++;
|
||||
ctx2d.arc = () => C.arc++;
|
||||
|
||||
function mkEl(id = '') {
|
||||
return {
|
||||
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
|
||||
style: {}, dataset: {}, children: [], width: 800, height: 608,
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
addEventListener: noop, removeEventListener: noop, appendChild: noop,
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d, focus: noop, click: noop,
|
||||
};
|
||||
}
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: addL, removeEventListener: noop, AudioContext: null, webkitAudioContext: null,
|
||||
innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.document = {
|
||||
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
|
||||
querySelector(s) { return mkEl(s); },
|
||||
querySelectorAll() { return []; },
|
||||
createElement(t) { const e = mkEl(); e.tagName = t.toUpperCase(); return e; },
|
||||
addEventListener: addL, body: mkEl('body'),
|
||||
};
|
||||
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
const resetC = () => { for (const k in C) C[k] = 0; };
|
||||
|
||||
const { Editor } = await import('../js/editor.js');
|
||||
const { PALETTE } = await import('../js/editor.js');
|
||||
|
||||
console.log('=== 编辑器初始化(会经 buildPalette 渲染全部调色板方块)===');
|
||||
try {
|
||||
Editor.init({ startLevel: noop });
|
||||
ok(true, 'Editor.init 成功(所有调色板方块缩略图渲染无异常)');
|
||||
} catch (e) { ok(false, 'Editor.init 失败: ' + e.message); console.log(e.stack.split('\n').slice(0, 4).join('\n')); }
|
||||
|
||||
console.log('=== 打开空白地图并绘制 ===');
|
||||
Editor.open();
|
||||
const f = Editor.curFace();
|
||||
ok(f && f.w > 0 && f.h > 0, 'open 生成可编辑面(' + f.w + '×' + f.h + ')');
|
||||
|
||||
console.log('=== 刺在编辑器里以三角形绘制 ===');
|
||||
f.grid[1][1] = '^';
|
||||
resetC();
|
||||
Editor.draw();
|
||||
ok(C.lineTo > 0 && C.beginPath > 0, '刺:用三角形图元绘制(lineTo=' + C.lineTo + ', beginPath=' + C.beginPath + ')');
|
||||
|
||||
console.log('=== 方块在编辑器里以真实纹理绘制 ===');
|
||||
f.grid[1][1] = '#';
|
||||
resetC();
|
||||
Editor.draw();
|
||||
ok(C.fillRect > 0, '方块:实心纹理绘制(fillRect=' + C.fillRect + ')');
|
||||
|
||||
console.log('=== 每种方块类型在编辑器画布都能正常绘制不抛异常 ===');
|
||||
let bad = 0;
|
||||
for (const [ch] of PALETTE) {
|
||||
if (ch === '.') continue;
|
||||
try { f.grid[2][2] = ch; Editor.draw(); }
|
||||
catch (e) { console.log(' ✗ ' + ch + ': ' + e.message); bad++; }
|
||||
}
|
||||
ok(bad === 0, '全部 ' + PALETTE.filter(p => p[0] !== '.').length + ' 种方块绘制无异常');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 编辑器所见即所得 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,103 @@
|
||||
// 冰面惯性回归测试:滑出冰块后必须保留惯性(空中几乎不衰减,地面缓慢收住)
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player, PHY } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 44;
|
||||
// 地板 row 11:冰面 col1..6,之后 tail(普通地面或空)
|
||||
function grid(tail) {
|
||||
const g = [];
|
||||
g.push('#'.repeat(W));
|
||||
for (let y = 1; y <= 10; y++) {
|
||||
g.push((y === 10 ? '#S' + '.'.repeat(W - 3) : '#' + '.'.repeat(W - 2)) + '#');
|
||||
}
|
||||
g.push('#' + '%'.repeat(6) + tail + '#');
|
||||
g.push('#'.repeat(W));
|
||||
g.push('#'.repeat(W));
|
||||
return g;
|
||||
}
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
|
||||
function run(tail, label, releaseAt = 1e9, jumpAt = 1e9) {
|
||||
const L = new Level({ id: 'ice', name: 'ICE', type: 'jump', width: W, grid: grid(tail) });
|
||||
const P = new Player(L);
|
||||
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
down('ArrowRight');
|
||||
const log = [];
|
||||
for (let i = 0; i < 90; i++) {
|
||||
if (i === releaseAt) up('ArrowRight');
|
||||
if (i === jumpAt) down('ShiftLeft');
|
||||
if (i === jumpAt + 1) up('ShiftLeft');
|
||||
L.update(1, P, null); P.update(1); Input.endFrame();
|
||||
log.push({
|
||||
f: i, x: +P.x.toFixed(1), vx: +P.vx.toFixed(2), y: +P.y.toFixed(1),
|
||||
g: P.grounded, sl: P.sliding,
|
||||
ice: P.grounded && L.iceAtBottom(P.x, P.y, P.w, P.h),
|
||||
});
|
||||
}
|
||||
up('ArrowRight');
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log('帧 x vx 地 滑 冰');
|
||||
let crossAt = -1;
|
||||
for (const r of log) {
|
||||
const cross = !r.ice && log.find(o => o.f === r.f - 1)?.ice;
|
||||
if (cross && crossAt < 0) { crossAt = r.f; console.log('--- 离开冰面 ---'); }
|
||||
if (r.f % 3 === 0 || cross) console.log(`${String(r.f).padStart(3)} ${String(r.x).padStart(6)} ${String(r.vx).padStart(6)} ${r.g ? 'Y' : '.'} ${r.sl ? 'Y' : '.'} ${r.ice ? 'Y' : '.'}`);
|
||||
}
|
||||
const off = log.filter(r => r.f >= crossAt && crossAt >= 0);
|
||||
const dist = off.length ? Math.max(...off.map(r => r.x)) - off[crossAt === 0 ? 0 : 0].x : 0;
|
||||
console.log(`离冰时 vx=${off[0]?.vx ?? 0},离冰后位移=${dist.toFixed(1)}px`);
|
||||
return { log, crossAt, off };
|
||||
}
|
||||
|
||||
// A:一直按住右键冲出冰面 —— 惯性要把 4.32 平滑收敛到正常走速,不能瞬间刹停也不能一直超速
|
||||
{
|
||||
const { off } = run('='.repeat(34), 'A 冰面 -> 普通地面(一直按住右键)');
|
||||
const v0 = off[0].vx, v10 = off[10]?.vx ?? 0, v30 = off[30]?.vx ?? 0;
|
||||
if (v0 < PHY.HSPD) err(`A 离冰瞬间就丢了速度 vx=${v0}`);
|
||||
if (v10 < 2) err(`A 离冰 10 帧后速度掉太快 vx=${v10}(应仍在滑行)`);
|
||||
if (Math.abs(v30) > PHY.HSPD + 0.01) err(`A 离冰 30 帧后仍超速 vx=${v30}(应收敛回 ${PHY.HSPD})`);
|
||||
}
|
||||
|
||||
// B:松手靠惯性滑出到普通地面 —— 地面摩擦收住,但不能一帧刹死
|
||||
{
|
||||
const { off } = run('='.repeat(34), 'B 冰面 -> 普通地面(第30帧松开方向键)', 30);
|
||||
const v0 = off[0].vx, v5 = off[5]?.vx ?? 0;
|
||||
if (v5 <= 0) err(`B 离冰 5 帧内就刹死了(v0=${v0} v5=${v5})`);
|
||||
if (v5 > v0) err(`B 松手后速度反而上升`);
|
||||
}
|
||||
|
||||
// C:滑出冰块边缘掉下去 —— 空中必须几乎不衰减,能一路飞出去
|
||||
{
|
||||
const { off } = run('.'.repeat(34), 'C 冰面 -> 悬空(第30帧松开后滑出边缘)', 30);
|
||||
const air = off.filter(r => !r.g);
|
||||
if (air.length < 5) { err('C 没掉下去(场景构造失败)'); }
|
||||
else {
|
||||
const k = Math.min(10, air.length - 1);
|
||||
const v0 = air[0].vx, vk = air[k].vx;
|
||||
if (vk < v0 - 0.5) err(`C 空中惯性衰减过快:${v0} -> ${vk}(${k} 帧)`);
|
||||
else console.log(` ✓ 空中 ${k} 帧后仍保留 vx=${vk}(起 ${v0})`);
|
||||
}
|
||||
}
|
||||
|
||||
// D:冰面上滑行中起跳 —— 空中保持滑行速度
|
||||
{
|
||||
const { off } = run('.'.repeat(34), 'D 冰面滑行中起跳(第30帧松开 + 第34帧跳)', 30, 34);
|
||||
const v0 = off[0]?.vx ?? 0, v12 = off[12]?.vx ?? 0;
|
||||
if (v0 < 2) err(`D 起跳时速度太小 vx=${v0}`);
|
||||
if (v12 < v0 - 0.6) err(`D 跳跃后空中丢失惯性:${v0} -> ${v12}`);
|
||||
else console.log(` ✓ 起跳后 12 帧仍保留 vx=${v12}(起 ${v0})`);
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 冰面惯性全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,54 @@
|
||||
// 用真实引擎(Player/Level)测量跳跃水平距离,核对求解器的物理近似是否可信
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
// 一块 40 格长的平地,右端断开
|
||||
const flat = {
|
||||
id: 'test', name: 'T', type: 'jump', width: 44,
|
||||
grid: (() => {
|
||||
const rows = [];
|
||||
rows.push('#'.repeat(44));
|
||||
for (let i = 0; i < 16; i++) rows.push('#' + '.'.repeat(42) + '#');
|
||||
rows.push('#' + '#'.repeat(42) + '#'); // 整条实心平台
|
||||
rows.push('#'.repeat(44));
|
||||
return rows;
|
||||
})(),
|
||||
};
|
||||
|
||||
function measure(djFrame) {
|
||||
const L = new Level(flat);
|
||||
const P = new Player(L);
|
||||
P.x = 8 * 32; P.y = 16 * 32 - P.h; // 站在 row17 平台上(平台行 17)
|
||||
P.vx = 0; P.vy = 0; P.grounded = true; P.jumps = 0;
|
||||
const x0 = P.x;
|
||||
key('keydown', 'ArrowRight');
|
||||
key('keydown', 'ShiftLeft');
|
||||
let f = 0, landed = -1, released = false;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
f++;
|
||||
// 二段跳需要先松开再按下(跨帧),否则不算一次新的按下
|
||||
if (djFrame && f === djFrame) { key('keyup', 'ShiftLeft'); released = true; }
|
||||
if (released && f === djFrame + 1) { key('keydown', 'ShiftLeft'); released = false; }
|
||||
L.update(1, P, null);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (i > 2 && P.grounded) { landed = P.x; break; }
|
||||
if (P.dead) break;
|
||||
}
|
||||
key('keyup', 'ArrowRight'); key('keyup', 'ShiftLeft');
|
||||
Input.endFrame();
|
||||
return { dist: landed >= 0 ? landed - x0 : null, f };
|
||||
}
|
||||
|
||||
console.log('真实引擎跳跃水平距离(起跳点 x 到落点 x,单位 px / 格)');
|
||||
for (const dj of [0, 6, 12, 18, 24, 30]) {
|
||||
const r = measure(dj);
|
||||
console.log(` 二段跳时机=${String(dj).padStart(2)} 帧 -> ${r.dist === null ? '未落地' : r.dist.toFixed(1) + 'px = ' + (r.dist / 32).toFixed(2) + ' 格 (' + r.f + ' 帧)'}`);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 回归测试:R 键必须"重生回存档点",不能把整关重开(否则存档点白碰)
|
||||
const noop = () => { };
|
||||
const els = new Map();
|
||||
function mkEl(id = '') {
|
||||
return {
|
||||
id, tagName: 'DIV', value: '', textContent: '', innerHTML: '',
|
||||
style: {}, dataset: {}, children: [], width: 800, height: 608,
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
addEventListener: noop, removeEventListener: noop, appendChild: noop,
|
||||
querySelector: () => mkEl(), querySelectorAll: () => [],
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 608 }),
|
||||
getContext: () => ctx2d, focus: noop, click: noop,
|
||||
};
|
||||
}
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
|
||||
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
const key = (t, c) => (handlers[t] || []).forEach(fn => fn({ code: c, preventDefault() { }, repeat: false }));
|
||||
|
||||
let clock = 1000;
|
||||
globalThis.performance = { now: () => clock };
|
||||
let rafCb = null;
|
||||
globalThis.requestAnimationFrame = fn => { rafCb = fn; return 1; };
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = {
|
||||
addEventListener: addL, removeEventListener: noop,
|
||||
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
|
||||
location: globalThis.location, navigator: globalThis.navigator,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
};
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
globalThis.localStorage = {
|
||||
_d: {}, getItem(k) { return this._d[k] ?? null; },
|
||||
setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; },
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) { if (!els.has(id)) els.set(id, mkEl(id)); return els.get(id); },
|
||||
querySelector: s => mkEl(s), querySelectorAll: () => [],
|
||||
createElement: t => mkEl(), addEventListener: addL, body: mkEl('body'),
|
||||
};
|
||||
|
||||
await import('../js/main.js');
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const api = globalThis.window.__IW;
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
const step = n => { for (let i = 0; i < n; i++) { clock += 17; const f = rafCb; rafCb = null; if (f) f(clock); } };
|
||||
|
||||
const d = LEVELS.find(l => l.id === 'j1');
|
||||
api.startLevel(d, 0);
|
||||
step(10);
|
||||
|
||||
const L0 = api.G.level, P = api.G.player;
|
||||
// 手动吃一个存档点(模拟玩家走到那里)
|
||||
const s = L0.entities.filter(e => e.type === 'save')[1];
|
||||
P.x = s.x + s.w / 2 - P.w / 2; P.y = s.y + s.h - P.h;
|
||||
step(3);
|
||||
const cp = P.savePoint;
|
||||
console.log(` 存档点激活=${!!cp} 位置=(${cp?.x.toFixed(0)},${cp?.y.toFixed(0)}) 关卡出生点x=${L0.spawn.x.toFixed(0)}`);
|
||||
if (!cp) err('存档点未激活');
|
||||
|
||||
// ---- 按 R(活着)= 原地播死亡动画后当帧重生回存档点(不计死亡、不留红叉、不等动画)----
|
||||
const deaths0 = api.G.deaths;
|
||||
const marks0 = L0.deathMarks.length;
|
||||
P.x = L0.spawn.x; P.y = L0.spawn.y; // 先站到出生点,验证能回到存档点而非原地不动
|
||||
step(5);
|
||||
const beforeX = api.G.player.x;
|
||||
key('keydown', 'KeyR');
|
||||
step(2);
|
||||
key('keyup', 'KeyR');
|
||||
console.log(` 活着按 R 后:玩家x=${beforeX.toFixed(0)}→${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} dead=${api.G.player.dead} 死亡数=${deaths0}→${api.G.deaths} 红叉数=${marks0}→${L0.deathMarks.length} 粒子=${L0.particles.list.length} 冲击环=${L0.deathFX.length}`);
|
||||
if (api.G.player.dead || !api.G.player.alive) err('活着按 R 没有当帧重生');
|
||||
if (api.G.deaths !== deaths0) err('主动重生被计入了死亡数(主动重生不是死亡)');
|
||||
if (L0.deathMarks.length !== marks0) err('主动重生留下了红叉(不应留血迹)');
|
||||
if (Math.abs(api.G.player.x - cp.x) > 4) err(`按 R 没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
|
||||
if (!(L0.particles.list.length > 0)) err('死亡演出粒子缺失(应留在死亡位置播完)');
|
||||
if (!L0.deathFX.length) err('死亡演出冲击环缺失(应留在死亡位置播完)');
|
||||
|
||||
// ---- 死亡中按 R = 手动重生回存档点 ----
|
||||
api.G.player.kill();
|
||||
step(3);
|
||||
if (!api.G.player.dead) err('kill 后未进入死亡定格状态');
|
||||
key('keydown', 'KeyR');
|
||||
step(2);
|
||||
key('keyup', 'KeyR');
|
||||
step(10);
|
||||
|
||||
const survivedLevel = api.G.level === L0;
|
||||
console.log(` 死亡中按 R 重生后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} alive=${api.G.player.alive} 残余粒子=${L0.particles.list.length}`);
|
||||
if (!survivedLevel) err('R 键把整关重开了,存档进度丢失');
|
||||
if (api.G.player.dead || !api.G.player.alive) err('死亡中按 R 未手动重生');
|
||||
if (Math.abs(api.G.player.x - cp.x) > 4) err(`重生后没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
|
||||
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) err('重生后被送回了关卡起点');
|
||||
if (!(L0.particles.list.length > 0)) err('死亡中按 R 重生把特效清掉了(应留在死亡位置播完)');
|
||||
|
||||
// ---- 按 Backspace = 整关重来 ----
|
||||
key('keydown', 'Backspace');
|
||||
step(2);
|
||||
key('keyup', 'Backspace');
|
||||
step(10);
|
||||
console.log(` 按 Backspace 后:关卡对象被重建=${api.G.level !== L0} 玩家x=${api.G.player.x.toFixed(0)}`);
|
||||
if (api.G.level === L0) err('Backspace 没有整关重开');
|
||||
if (Math.abs(api.G.player.x - api.G.level.spawn.x) > 4) err('Backspace 后玩家不在出生点');
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 按键语义正确:R=回存档点,Backspace=整关重来');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,169 @@
|
||||
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
|
||||
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
|
||||
export const TILE = 32, PW = 11, PH = 21;
|
||||
export const GRAV = 0.25, GRAV_FALL = 0.32, GRAV_DJ = 0.35, MAXFALL = 9, JUMP = 6.6, DJUMP = 6.7, HSPD = 2.6, DJ_INERTIA = 0;
|
||||
|
||||
export function setupGlobals() {
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export function makeSim(L) {
|
||||
// 水:近似为可站立
|
||||
for (let y = 0; y < L.h; y++)
|
||||
for (let x = 0; x < L.w; x++)
|
||||
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
|
||||
// 移动平台:整条路径当桥(乐观近似)
|
||||
const br = [];
|
||||
for (const m of L.movers) {
|
||||
const xs = m.path.map(p => p.x).concat([m.x]);
|
||||
const ys = m.path.map(p => p.y).concat([m.y]);
|
||||
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
|
||||
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
|
||||
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
||||
}
|
||||
|
||||
function solidAt(x, y, w, h, down, prevBottom) {
|
||||
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
|
||||
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
|
||||
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||||
const t = L.tileAt(tx, ty);
|
||||
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
|
||||
if (L.isSolidTile(t, tx, ty)) return true;
|
||||
}
|
||||
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function canStand(fx, fy) {
|
||||
if (fx < 0 || fx + PW > L.pxW) return false;
|
||||
if (solidAt(fx, fy - PH, PW, PH, false)) return false;
|
||||
if (!solidAt(fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
|
||||
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
|
||||
function jump(fx, fy, vx0, djump, flip, rel, targets) {
|
||||
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false, djUp = false;
|
||||
while (t++ < 240) {
|
||||
// 与 player.js 一致:上升中按二段 = 直接 -DJUMP;下落中按只抵消 DJ_INERTIA 比例的下落速度
|
||||
if (djump && !used && t === djump) { vy = Math.max(vy, 0) * DJ_INERTIA - DJUMP; used = true; djUp = vy < 0; }
|
||||
if (flip && t === flip) vx = -vx;
|
||||
if (rel && t === rel && vy < -3) vy = -3;
|
||||
vy = Math.min(vy + (vy < 0 ? (djUp ? GRAV_DJ : GRAV) : GRAV_FALL), MAXFALL);
|
||||
if (djUp && vy >= 0) djUp = false;
|
||||
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
|
||||
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
|
||||
if (vx !== 0) {
|
||||
const nx = x + vx;
|
||||
if (!solidAt(nx, y, PW, PH, false)) x = nx;
|
||||
}
|
||||
const prevBottom = y + PH;
|
||||
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
|
||||
while (rem > 0.0001) {
|
||||
const m = Math.min(1, rem), ny = y + dy * m;
|
||||
if (!solidAt(x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
|
||||
else { if (dy > 0) landed = true; vy = 0; break; }
|
||||
}
|
||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
||||
if (targets) {
|
||||
const box = { x, y, w: PW, h: PH };
|
||||
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
||||
}
|
||||
if (y > L.pxH + 120 || y < -240) return null;
|
||||
if (landed) return { fx: x, fy: y + PH };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fall(fx, fy, dir, targets) {
|
||||
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
|
||||
if (solidAt(x, y, PW, PH, false)) return null;
|
||||
while (t++ < 400) {
|
||||
vy = Math.min(vy + (vy < 0 ? GRAV : GRAV_FALL), MAXFALL);
|
||||
const prevBottom = y + PH;
|
||||
let rem = Math.abs(vy), landed = false;
|
||||
while (rem > 0.0001) {
|
||||
const m = Math.min(1, rem), ny = y + m;
|
||||
if (!solidAt(x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
|
||||
else { landed = true; break; }
|
||||
}
|
||||
if (L.deadlyRect(x, y, PW, PH)) return null;
|
||||
if (targets) {
|
||||
const box = { x, y, w: PW, h: PH };
|
||||
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
|
||||
}
|
||||
if (y > L.pxH + 120) return null;
|
||||
if (landed) return { fx: x, fy: y + PH };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { solidAt, canStand, jump, fall, overlap };
|
||||
}
|
||||
|
||||
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
|
||||
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
|
||||
// 2026-10-23:加 rel=5/6。vgap 夹缝(缝高 32px、盒高 21px)要求短跳 apex 精确落在 32~43px,
|
||||
// 且上升早期要躲开地刺左坡:rel=4 apex 35.7 但 t=7 蹭坡 2.6px,rel=6 apex 44.7 但 t=11 擦垂刺尖 0.6px,
|
||||
// 只有 rel=5(apex 38.9)同时满足(j2 x16 夹缝实证);j7 顶层 x52 夹缝则要 rel=6
|
||||
export const DEF_REL = [0, 4, 5, 6, 8, 14];
|
||||
|
||||
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
|
||||
const sim = makeSim(L);
|
||||
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
|
||||
for (let k = 0; k < 400; k++) {
|
||||
if (sim.canStand(sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
|
||||
if (sy + k > L.pxH) break;
|
||||
}
|
||||
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
|
||||
|
||||
// 3px 状态桶:6px 桶会把"贴右腰 1~3px"的起拍/落点窗量化吞掉(j2 连续三段因此误判不可达)
|
||||
// fy 规范化:jump/fall 返回的落点常比地面高零点几 px(如 1535.5),与精确落点 1536 同桶——
|
||||
// 先占桶者赢,导致走链被错位几 px 的跳链顶掉,芝麻距离的跳跃窗口翻转(加 rel=6 后 j2 状态数反而暴跌的根因)。
|
||||
// 真实游戏里重力下一帧即把玩家按到底(地面顶 = 32 的倍数),故按 canStand 门控 snapping
|
||||
const key = s => `${Math.round(s.fx / 3)},${Math.round(s.fy / 3)}`;
|
||||
const canon = (fx, fy) => {
|
||||
const r = Math.round(fy / TILE) * TILE;
|
||||
return (r !== fy && sim.canStand(fx, r)) ? r : fy;
|
||||
};
|
||||
start.fy = canon(start.fx, start.fy);
|
||||
const seen = new Set([key(start)]);
|
||||
const q = [start];
|
||||
let n = 0;
|
||||
while (q.length && n < maxStates) {
|
||||
const s = q.shift(); n++;
|
||||
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
|
||||
for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
|
||||
const push = r => {
|
||||
if (!r || !sim.canStand(r.fx, r.fy)) return;
|
||||
r.fy = canon(r.fx, r.fy);
|
||||
const k = key(r);
|
||||
if (seen.has(k)) return;
|
||||
seen.add(k); q.push(r);
|
||||
if (onState) onState(r);
|
||||
};
|
||||
for (const d of [-HSPD, HSPD]) push(sim.canStand(s.fx + d, s.fy) ? { fx: s.fx + d, fy: s.fy } : null);
|
||||
for (const d of [-1, 1]) push(sim.fall(s.fx, s.fy, d, targets));
|
||||
for (const vx of [-HSPD, 0, HSPD]) for (const dj of DJSET) for (const rel of RELSET) {
|
||||
if (dj && rel) continue;
|
||||
push(sim.jump(s.fx, s.fy, vx, dj, 0, rel, targets));
|
||||
if (vx !== 0) push(sim.jump(s.fx, s.fy, vx, dj, 22, rel, targets));
|
||||
}
|
||||
}
|
||||
return { states: seen.size, targets };
|
||||
}
|
||||
|
||||
export function makeTargets(L) {
|
||||
const targets = [];
|
||||
for (const e of L.entities) {
|
||||
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
|
||||
targets.push({ type: e.type, idx: e.idx, no: e.no, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 回归测试:多面地图的跨面传送门 + 存档点带面 + 内置关卡兼容
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 20, TILE = 32;
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
const ok = m => console.log(' ✓ ' + m);
|
||||
|
||||
// 两面临门地图:A 面门 col5 指向 B 面门;B 面门 col10 指回 A 面门
|
||||
const gridA = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
|
||||
const gridB = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(9) + 'W' + '.'.repeat(W - 11) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
|
||||
const doorA = { id: 1, tx: 5, ty: 2, to: { face: 1, door: 1 } };
|
||||
const doorB = { id: 1, tx: 10, ty: 2, to: { face: 0, door: 1 } };
|
||||
const L0 = new Level({ name: 'A', type: 'custom', width: W, grid: gridA, doors: [doorA] }); L0.faceIndex = 0;
|
||||
const L1 = new Level({ name: 'B', type: 'custom', width: W, grid: gridB, doors: [doorB] }); L1.faceIndex = 1;
|
||||
|
||||
// 1) 门目标正确挂接
|
||||
{
|
||||
const w0 = L0.entities.find(e => e.type === 'warp');
|
||||
const w1 = L1.entities.find(e => e.type === 'warp');
|
||||
if (!w0 || !w1) err('门实体未生成');
|
||||
else {
|
||||
if (w0.to && w0.to.face === 1 && w0.to.door === 1 && w0.doorId === 1) ok('A 面门挂上跨面目标 {face:1,door:1}');
|
||||
else err('A 面门目标错误: ' + JSON.stringify(w0.to));
|
||||
if (w1.to && w1.to.face === 0 && w1.to.door === 1 && w1.doorId === 1) ok('B 面门挂上跨面目标 {face:0,door:1}');
|
||||
else err('B 面门目标错误: ' + JSON.stringify(w1.to));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 踩门触发 onPortal 钩子(main 据此切面)
|
||||
{
|
||||
const P = new Player(L0);
|
||||
let captured = null; P.onPortal = (e) => { captured = e; };
|
||||
const w0 = L0.entities.find(e => e.type === 'warp');
|
||||
P.x = w0.x; P.y = w0.y; P.vx = 0; P.vy = 0;
|
||||
L0.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (captured && captured.to && captured.to.face === 1) ok('踩到门触发 onPortal,目标面=1');
|
||||
else err('踩门未触发跨面 onPortal');
|
||||
}
|
||||
|
||||
// 3) 存档点带 faceIndex
|
||||
{
|
||||
const gridC = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + 'G' + '.'.repeat(W - 3) + '#', '#X' + '.'.repeat(W - 3) + '#'];
|
||||
const LC = new Level({ name: 'C', type: 'custom', width: W, grid: gridC, doors: [] }); LC.faceIndex = 2;
|
||||
const P = new Player(LC);
|
||||
const g = LC.entities.find(e => e.type === 'save');
|
||||
P.x = g.x; P.y = g.y;
|
||||
LC.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (P.savePoint && P.savePoint.face === 2) ok('存档点记录 face=2');
|
||||
else err('存档点未带 face,得到 ' + (P.savePoint && P.savePoint.face));
|
||||
}
|
||||
|
||||
// 4) 兼容:无 doors 的内置风格关卡,门仍走原有 pair 逻辑(to 为 null)
|
||||
{
|
||||
const gridD = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#X' + '.'.repeat(W - 3) + '#'];
|
||||
const LD = new Level({ name: 'D', type: 'jump', width: W, grid: gridD });
|
||||
const ws = LD.entities.filter(e => e.type === 'warp');
|
||||
if (ws.length === 2 && ws[0].to === null && ws[1].to === null) ok('无 doors 时门 to=null(走原有 pair 逻辑,不退化)');
|
||||
else err('无 doors 时门目标异常: ' + JSON.stringify(ws.map(w => w.to)));
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 多面地图/传送门 全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,31 @@
|
||||
// 诊断:打印可达站立点范围(按行),找出断连位置
|
||||
// 用法: node reach-diag.mjs <levelId>
|
||||
import { setupGlobals, reach, TILE } from './lib-reach.mjs';
|
||||
setupGlobals();
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
|
||||
const id = process.argv[2] || 'j6';
|
||||
const d = LEVELS.find(l => l.id === id);
|
||||
const L = new Level(d);
|
||||
|
||||
const byRow = new Map();
|
||||
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
|
||||
const r = reach(L, {
|
||||
DJSET, RELSET,
|
||||
onState: s => {
|
||||
const row = Math.round(s.fy / TILE);
|
||||
const cur = byRow.get(row) || { max: -1, min: 1e9 };
|
||||
byRow.set(row, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`${id} ${d.name} ${L.w}x${L.h} 状态=${r.states}${r.err ? ' ERR:' + r.err : ''}`);
|
||||
[...byRow.keys()].sort((a, b) => b - a).forEach(row => {
|
||||
const v = byRow.get(row);
|
||||
console.log(` row ${String(row).padStart(2)} (y=${row * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
|
||||
});
|
||||
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
|
||||
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
|
||||
@@ -0,0 +1,31 @@
|
||||
// 诊断:每个存档点的放置环境(脚下是什么 / 重生点是否致命 / 是否在路径上)
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
const { T } = await import('../js/level.js');
|
||||
|
||||
const NAME = { 0: '空', 1: '块', 2: '草', 3: '砖', 4: '金属', 5: '冰', 6: '水', 7: '单向', 8: '即死', 9: '消失', 10: '上刺', 11: '下刺', 12: '左刺', 13: '右刺' };
|
||||
|
||||
for (const d of LEVELS) {
|
||||
const L = new Level(d);
|
||||
const P = new Player(L);
|
||||
const saves = L.entities.filter(e => e.type === 'save');
|
||||
if (!saves.length) { console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ⚠ 无存档点`); continue; }
|
||||
const lines = [];
|
||||
for (const s of saves) {
|
||||
const below = L.tileAt(s.tx, s.ty + 1);
|
||||
const above = L.tileAt(s.tx, s.ty - 1);
|
||||
// 重生点
|
||||
const rx = s.x + s.w / 2 - P.w / 2, ry = s.y + s.h - P.h;
|
||||
const deadly = L.deadlyRect(rx, ry, P.w, P.h);
|
||||
const solid = L.solidRect(rx, ry, P.w, P.h);
|
||||
lines.push(`(${s.tx},${s.ty}) 脚=${NAME[below] || below} 头=${NAME[above] || above}${deadly ? ' ❌重生即死' : ''}${solid ? ' ❌卡墙里' : ''}`);
|
||||
}
|
||||
console.log(`${d.id.padEnd(3)} ${d.name.padEnd(16)} ${saves.length} 个: ${lines.join(' | ')}`);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 存档点专项测试:触碰 -> 激活 -> 死亡 -> 回到存档点
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
|
||||
for (const d of LEVELS) {
|
||||
const L = new Level(d);
|
||||
const P = new Player(L);
|
||||
const saves = L.entities.filter(e => e.type === 'save');
|
||||
if (!saves.length) { console.log(` ${d.id} 无存档点`); continue; }
|
||||
|
||||
// 依次把玩家瞬移到每个存档点,检查是否激活
|
||||
for (const s of saves) {
|
||||
P.x = s.x + s.w / 2 - P.w / 2;
|
||||
P.y = s.y + s.h - P.h;
|
||||
P.vx = 0; P.vy = 0; P.dead = false; P.alive = true;
|
||||
L.update(1, P, null);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (!s.active) err(`${d.id} 存档点(${s.tx},${s.ty}) 触碰后未激活`);
|
||||
if (!P.savePoint) err(`${d.id} 存档点(${s.tx},${s.ty}) 未写入 player.savePoint`);
|
||||
}
|
||||
|
||||
// 死亡后应回到最后一个存档点
|
||||
const last = saves[saves.length - 1];
|
||||
const want = { x: last.x + last.w / 2 - P.w / 2, y: last.y + last.h - P.h };
|
||||
const spawn = L.spawn;
|
||||
P.kill(); // 死亡后不再自动重生(新语义:定格等待手动重生)
|
||||
P.respawn(); // 模拟玩家按 R 手动重生,应回到最后一个存档点
|
||||
for (let i = 0; i < 45; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
const dx = Math.abs(P.x - want.x), dy = Math.abs(P.y - want.y);
|
||||
const backToStart = Math.abs(P.x - spawn.x) < 3 && Math.abs(P.y - spawn.y) < 3;
|
||||
const ok = dx < 2 && dy < 2;
|
||||
console.log(` ${d.id.padEnd(3)} 存档=${saves.length} 重生=(${P.x.toFixed(0)},${P.y.toFixed(0)}) 期望=(${want.x.toFixed(0)},${want.y.toFixed(0)}) ${ok ? 'OK' : (backToStart ? '❌回到了起点' : '❌位置不符')}`);
|
||||
if (!ok) err(`${d.id} 死亡后未回到存档点`);
|
||||
|
||||
// 重生后是否立刻死亡
|
||||
const alive0 = P.alive;
|
||||
for (let i = 0; i < 20; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
if (alive0 && !P.alive) err(`${d.id} 在存档点重生后立刻死亡`);
|
||||
|
||||
// 存档点状态是否在软重置后保留
|
||||
if (!last.active) err(`${d.id} 软重置后存档点激活状态丢失`);
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 存档点全部正常');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,223 @@
|
||||
// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认
|
||||
const noop = () => { };
|
||||
|
||||
class El {
|
||||
constructor(tag = 'DIV') {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.value = '';
|
||||
this.textContent = '';
|
||||
this._html = '';
|
||||
this._classes = new Set();
|
||||
this._style = {};
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this._ev = {};
|
||||
this.parent = null;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: c => self._classes.add(c),
|
||||
remove: c => self._classes.delete(c),
|
||||
contains: c => self._classes.has(c),
|
||||
toggle: (c, f) => { const on = f === undefined ? !self._classes.has(c) : f; on ? self._classes.add(c) : self._classes.delete(c); return on; },
|
||||
};
|
||||
}
|
||||
get style() { return this._style; }
|
||||
set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
||||
get className() { return [...this._classes].join(' '); }
|
||||
set innerHTML(v) { this._html = v; if (v === '') this.children = []; }
|
||||
get innerHTML() { return this._html; }
|
||||
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
||||
remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
|
||||
querySelector() { return new El(); }
|
||||
querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; }
|
||||
closest(sel) { const cls = sel.replace(/^\./, ''); let n = this; while (n) { if (n._classes && n._classes.has(cls)) return n; n = n.parent; } return null; }
|
||||
addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
|
||||
removeEventListener() { }
|
||||
dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
|
||||
setPointerCapture() { }
|
||||
releasePointerCapture() { }
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 608 }; }
|
||||
}
|
||||
|
||||
const byId = new Map();
|
||||
const touchMap = new Map();
|
||||
const docListeners = {};
|
||||
globalThis.localStorage = {
|
||||
_d: {},
|
||||
getItem(k) { return k in this._d ? this._d[k] : null; },
|
||||
setItem(k, v) { this._d[k] = String(v); },
|
||||
removeItem(k) { delete this._d[k]; },
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) { if (!byId.has(id)) byId.set(id, new El()); return byId.get(id); },
|
||||
createElement(t) { return new El(t); },
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/data-touch="([^"]+)"/);
|
||||
if (m) { const k = m[1]; if (!touchMap.has(k)) touchMap.set(k, new El()); return touchMap.get(k); }
|
||||
return new El();
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener(type, fn) { (docListeners[type] ||= []).push(fn); },
|
||||
removeEventListener() { },
|
||||
dispatch(type, ev) { (docListeners[type] || []).forEach(fn => fn(ev)); },
|
||||
};
|
||||
globalThis.window = { addEventListener: noop, removeEventListener: noop };
|
||||
globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); };
|
||||
globalThis.removeEventListener = noop;
|
||||
globalThis.confirm = () => true;
|
||||
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
|
||||
const { Save } = await import('../js/save.js');
|
||||
const { Settings } = await import('../js/settings.js');
|
||||
const { applyBindings, defaultBindings } = await import('../js/input.js');
|
||||
|
||||
console.log('=== input 键位 API ===');
|
||||
ok(defaultBindings().left.includes('ArrowLeft'), 'defaultBindings 含出厂左移键');
|
||||
applyBindings({ left: ['KeyA'], jump: ['Space'] });
|
||||
ok(true, 'applyBindings 调用不报错');
|
||||
|
||||
console.log('=== 初始化 / 默认布局(PC 模式)===');
|
||||
Settings.init(false);
|
||||
const s0 = Save.data.settings;
|
||||
ok(!!s0 && !!s0.touch, '存档含 settings.touch');
|
||||
const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide'];
|
||||
ok(keys.every(k => s0.touch[k]), '7 个手柄键都有默认布局');
|
||||
ok(s0.touch.left.cx === 12 && s0.touch.jump.size === 94, '默认坐标/尺寸正确');
|
||||
ok(byId.get('title-user').textContent === '玩家:无名氏', '标题默认显示「无名氏」');
|
||||
|
||||
console.log('=== apply 把布局写到真实手柄 ===');
|
||||
const L = touchMap.get('left'), J = touchMap.get('jump');
|
||||
ok(L._style.left === '12%' && L._style.top === '80%' && L._style.width === '62px', 'left 按钮定位/尺寸已应用');
|
||||
ok(J._style.width === '94px' && J._style.left === '88%', 'jump 按钮定位/尺寸已应用');
|
||||
|
||||
console.log('=== 进入 / 确定 保存用户名与布局 ===');
|
||||
Settings.enter();
|
||||
byId.get('set-username').value = '阿测玩家';
|
||||
Settings.confirm();
|
||||
ok(Save.data.settings.username === '阿测玩家', '用户名已保存到存档');
|
||||
ok(byId.get('title-user').textContent === '玩家:阿测玩家', '标题用户名已更新');
|
||||
ok(localStorage.getItem('iwbtc_fan_v1').includes('阿测玩家'), '已写入 localStorage');
|
||||
|
||||
console.log('=== 恢复默认 ===');
|
||||
byId.get('set-username').value = '随便';
|
||||
Settings.reset();
|
||||
ok(Settings._state().username === '', '恢复默认后用户名为空');
|
||||
ok(Settings._state().touch.jump.size === 94 && Settings._state().touch.left.cx === 12, '恢复默认后布局回到出厂值');
|
||||
// 恢复默认后必须再点确定才落盘
|
||||
ok(Save.data.settings.username === '阿测玩家', '恢复默认未自动落盘(需点确定)');
|
||||
Settings.confirm();
|
||||
ok(Save.data.settings.username === '', '点确定后空用户名落盘');
|
||||
|
||||
console.log('=== 布局越长越界会被限制(place 约束在交互层,这里校验默认在合法区间)===');
|
||||
ok(keys.every(k => { const p = Save.data.settings.touch[k]; return p.cx >= 4 && p.cx <= 96 && p.size >= 24 && p.size <= 160; }), '默认布局均在合法范围');
|
||||
|
||||
console.log('=== UI SCREENS 注册(防止设置屏永远不显示)===');
|
||||
const uiSrc = (await import('node:fs')).readFileSync(new URL('../js/ui.js', import.meta.url), 'utf8');
|
||||
ok(/SCREENS\s*=\s*\[[^\]]*'scr-settings'/.test(uiSrc), 'scr-settings 已加入 SCREENS 列表');
|
||||
ok(/data-act="settings"/.test((await import('node:fs')).readFileSync(new URL('../index.html', import.meta.url), 'utf8')), '标题菜单有「设置」入口');
|
||||
|
||||
console.log('=== 模拟拖动 / 缩放(防「按下即跳角」)===');
|
||||
Settings.enter(); // 重新同步工作副本
|
||||
const ev = (t, x, y) => ({ target: t, clientX: x, clientY: y, pointerId: 1, preventDefault: noop, stopPropagation: noop });
|
||||
const left = Settings._els().left;
|
||||
const handle = left.children.find(c => c._classes.has('tc-rsz')) || left.children[0];
|
||||
const wt = Settings._state().touch;
|
||||
const cx0 = wt.left.cx, cy0 = wt.left.cy;
|
||||
|
||||
// 在按钮中心 (96,486) 按下,向右拖 40px(预览宽 800 → +5%)
|
||||
left.dispatch('pointerdown', ev(left, 96, 486));
|
||||
left.dispatch('pointermove', ev(left, 136, 486));
|
||||
ok(Math.abs(wt.left.cx - (cx0 + 5)) < 0.01 && wt.left.cy === cy0, '拖动:cx 按位移增量变化,无跳角');
|
||||
// 再拖回原处
|
||||
left.dispatch('pointermove', ev(left, 96, 486));
|
||||
ok(Math.abs(wt.left.cx - cx0) < 0.01, '拖动:逆向位移可精确归位');
|
||||
left.dispatch('pointerup', ev(left, 96, 486));
|
||||
|
||||
// 缩放手柄:中心 (96,486),起点距中心 ~56,拉远到 ~113 → 尺寸翻倍
|
||||
const size0 = wt.left.size;
|
||||
handle.dispatch('pointerdown', ev(handle, 136, 526));
|
||||
handle.dispatch('pointermove', ev(handle, 176, 566));
|
||||
ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大(${size0}→${wt.left.size.toFixed(0)})`);
|
||||
handle.dispatch('pointerup', ev(handle, 176, 566));
|
||||
ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置');
|
||||
|
||||
console.log('=== 设备模式:PC 显示键盘映射,移动端显示手柄 ===');
|
||||
Settings.init(true); // isTouch=true → 移动端
|
||||
Settings.enter();
|
||||
ok(byId.get('set-keys-row')._classes.has('hidden'), '移动端:键盘映射面板隐藏');
|
||||
ok(!byId.get('set-touch-row')._classes.has('hidden'), '移动端:手柄面板显示');
|
||||
Settings.init(false); // PC
|
||||
Settings.enter();
|
||||
ok(!byId.get('set-keys-row')._classes.has('hidden'), 'PC:键盘映射面板显示');
|
||||
ok(byId.get('set-touch-row')._classes.has('hidden'), 'PC:手柄面板隐藏');
|
||||
|
||||
console.log('=== 键盘重绑:点击按键 → 按新键 ===');
|
||||
Settings.enter();
|
||||
const keysBox = byId.get('set-keys');
|
||||
const firstRow = keysBox.children[0]; // 左移行
|
||||
const kkeys = firstRow.children[1]; // kkeys 容器
|
||||
const chip = kkeys.children[0]; // 第一个按键 chip(left[0])
|
||||
ok(chip.dataset.kact === 'left' && String(chip.dataset.kidx) === '0', '首行首键为 left[0]');
|
||||
keysBox.dispatch('click', { target: chip }); // 进入重绑监听
|
||||
document.dispatch('keydown', { code: 'KeyB', preventDefault: noop }); // KeyB 未被任何动作占用
|
||||
ok(Settings._state().keys.left[0] === 'KeyB', '重绑:left[0] 变为空闲键 KeyB');
|
||||
// 添加第二个键
|
||||
const before = Settings._state().keys.left.length;
|
||||
const addBtn = kkeys.children[kkeys.children.length - 1]; // +添加
|
||||
keysBox.dispatch('click', { target: addBtn });
|
||||
document.dispatch('keydown', { code: 'KeyF', preventDefault: noop }); // KeyF 空闲
|
||||
ok(Settings._state().keys.left.length === before + 1 && Settings._state().keys.left.includes('KeyF'), '添加:left 多绑定了一个键 KeyF');
|
||||
// 删除第一个键(即刚重绑的 KeyB)
|
||||
const chip1 = kkeys.children[0];
|
||||
const rm = chip1.children.find(c => c._classes.has('krm'));
|
||||
keysBox.dispatch('click', { target: rm });
|
||||
ok(!Settings._state().keys.left.includes('KeyB') && Settings._state().keys.left.includes('KeyF'), '删除:left[0] 被移除(KeyB 消失,KeyF 仍在)');
|
||||
// 确定后落盘
|
||||
Settings.confirm();
|
||||
ok(Save.data.settings.keys.left.includes('KeyF'), '确定:键位变更已保存到存档');
|
||||
|
||||
console.log('=== 键盘冲突检测 ===');
|
||||
Settings.reset(); // 出厂:left=[ArrowLeft,KeyA]
|
||||
Settings.enter();
|
||||
const kb = byId.get('set-keys');
|
||||
const jumpRow = kb.children[3]; // 跳跃行(KEY_ACTIONS 第 4 项,down 已移除)
|
||||
const jumpKeys = jumpRow.children[1];
|
||||
const jumpChip = jumpKeys.children[0];
|
||||
ok(jumpChip.dataset.kact === 'jump', '找到跳跃行的按键');
|
||||
kb.dispatch('click', { target: jumpChip }); // 监听跳跃键
|
||||
document.dispatch('keydown', { code: 'KeyA', preventDefault: noop }); // KeyA 已被左移占用
|
||||
const st2 = Settings._state();
|
||||
ok(!st2.keys.jump.includes('KeyA'), '冲突:jump 不会绑定到被【左移】占用的 KeyA');
|
||||
ok(Settings._conflict() && Settings._conflict().act === 'jump' && Settings._conflict().label === '左移', '冲突:记录到被【左移】占用');
|
||||
ok(kb.children[0] && kb.children[0]._classes.has('kwarb'), '冲突:面板顶部显示警告横幅');
|
||||
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); // 换空闲键
|
||||
ok(st2.keys.jump[0] === 'KeyK', '冲突解除:改用空闲键 KeyK 成功');
|
||||
ok(Settings._conflict() === null, '冲突:解除后警告清空');
|
||||
// 同一动作内重复键也算冲突(jump 已含 KeyK,再绑到第二个槽应被拒)
|
||||
kb.dispatch('click', { target: jumpKeys.children[jumpKeys.children.length - 1] }); // +添加
|
||||
document.dispatch('keydown', { code: 'KeyK', preventDefault: noop });
|
||||
ok(st2.keys.jump.filter(c => c === 'KeyK').length === 1, '冲突:同一动作内重复键不重复写入');
|
||||
// Esc 取消监听
|
||||
kb.dispatch('click', { target: jumpChip });
|
||||
document.dispatch('keydown', { code: 'Escape', preventDefault: noop });
|
||||
ok(Settings._listening() === null && Settings._conflict() === null, 'Esc:退出监听且无残留警告');
|
||||
// 系统动作(菜单 KeyQ)也算冲突
|
||||
kb.dispatch('click', { target: jumpChip });
|
||||
document.dispatch('keydown', { code: 'KeyQ', preventDefault: noop });
|
||||
ok(Settings._conflict() && Settings._conflict().label === '菜单', '冲突:KeyQ 已被系统「菜单」占用');
|
||||
|
||||
console.log('=== 恢复默认:键位回到出厂 ===');
|
||||
Settings.reset();
|
||||
ok(Settings._state().keys.left.includes('ArrowLeft'), '恢复默认:left 含出厂 ArrowLeft');
|
||||
ok(Settings._state().keys.jump.includes('ShiftLeft'), '恢复默认:jump 含出厂 ShiftLeft');
|
||||
|
||||
console.log('=== 回归:main.js 中「确定」应返回上一页 ===');
|
||||
const mainSrc = (await import('node:fs')).readFileSync(new URL('../js/main.js', import.meta.url), 'utf8');
|
||||
ok(/settings:\s*\(\)\s*=>\s*\{\s*settingsFrom\s*=\s*UI\.cur/.test(mainSrc), '进入设置前记录来源界面');
|
||||
ok(/'set-ok':\s*\(\)\s*=>\s*\{\s*Settings\.confirm\(\);\s*UI\.show\(settingsFrom\)/.test(mainSrc), '确定后 UI.show(settingsFrom) 返回上一页');
|
||||
ok(/Settings\.init\(useTouch\)/.test(mainSrc), 'Settings.init 传入设备检测结果');
|
||||
|
||||
console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,105 @@
|
||||
// 无头冒烟测试:构造全部关卡 + 模拟输入跑物理
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
const down = c => key('keydown', c);
|
||||
const up = c => key('keyup', c);
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('./player.js'.replace('./player.js', '../js/player.js'));
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
|
||||
console.log('=== 关卡构造 ===');
|
||||
for (const d of LEVELS) {
|
||||
try {
|
||||
const L = new Level(d);
|
||||
const P = new Player(L);
|
||||
// 静止 60 帧
|
||||
for (let i = 0; i < 60; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
const grounded = P.grounded, alive = P.alive;
|
||||
console.log(` ${d.id.padEnd(3)} ${String(d.name).padEnd(16)} 实体=${String(L.entities.length).padStart(3)} boss=${L.bosses.length} 平台=${L.movers.length} 站住=${grounded ? 'Y' : 'N'} 存活=${alive ? 'Y' : 'N'}`);
|
||||
if (!grounded && d.type !== 'boss') err(`${d.id} 出生点悬空(60帧后未着地)`);
|
||||
if (!alive) err(`${d.id} 出生即死`);
|
||||
|
||||
// 模拟:向右跑 + 反复跳跃 400 帧
|
||||
down('ArrowRight');
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (i % 40 === 0) down('ShiftLeft');
|
||||
if (i % 40 === 8) up('ShiftLeft');
|
||||
if (i % 40 === 12) { down('ShiftLeft'); }
|
||||
if (i % 40 === 16) up('ShiftLeft');
|
||||
L.update(1, P, null);
|
||||
for (const b of L.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
}
|
||||
up('ArrowRight'); up('ShiftLeft');
|
||||
const moved = P.x;
|
||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) err(`${d.id} 坐标 NaN`);
|
||||
if (Math.abs(P.vx) > 20 || Math.abs(P.vy) > 40) err(`${d.id} 速度异常 vx=${P.vx} vy=${P.vy}`);
|
||||
console.log(` → 400帧后 x=${moved.toFixed(0)} y=${P.y.toFixed(0)} 死亡=${G_DEATHS(P)}`);
|
||||
} catch (e) {
|
||||
err(`${d.id} 运行异常: ${e.message}\n${e.stack?.split('\n')[1] || ''}`);
|
||||
}
|
||||
}
|
||||
function G_DEATHS() { return ''; }
|
||||
|
||||
console.log('\n=== 死亡与手动重生 ===');
|
||||
{
|
||||
const L = new Level(LEVELS.find(l => l.id === 'j1'));
|
||||
const P = new Player(L);
|
||||
let deaths = 0;
|
||||
P.onDeath = () => deaths++;
|
||||
for (let i = 0; i < 30; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
P.kill();
|
||||
// 新语义(2026-10-24):死亡后不自动重生,定格等待玩家手动按重生键
|
||||
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
if (P.alive) err('死亡后未等待手动重生(自动复活了)');
|
||||
else console.log(` 死亡计数=${deaths} 80帧后仍定格等待手动重生 ✓(deadT=${P.deadT})`);
|
||||
P.respawn();
|
||||
for (let i = 0; i < 10; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
if (!P.alive) err('手动重生失败');
|
||||
else console.log(` 手动重生后 alive ✓ 位置=(${P.x.toFixed(0)},${P.y.toFixed(0)})`);
|
||||
if (deaths !== 1) err('死亡回调未触发');
|
||||
}
|
||||
|
||||
console.log('\n=== 射击与 BOSS ===');
|
||||
{
|
||||
const L = new Level(LEVELS.find(l => l.id === 'b1'));
|
||||
const P = new Player(L);
|
||||
const boss = L.bosses[0];
|
||||
const hp0 = boss.hp;
|
||||
P.hasGun = true;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (i % 12 === 0) P.shootCool = 0, P.shoot();
|
||||
L.update(1, P, null);
|
||||
for (const b of L.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
Input.endFrame();
|
||||
if (boss.hp <= 0) break;
|
||||
}
|
||||
console.log(` BOSS hp ${hp0} → ${boss.hp} 弹幕数=${L.ebullets.length} 阶段=${boss.phase}`);
|
||||
if (boss.hp >= hp0) err('子弹没有对 BOSS 造成伤害');
|
||||
}
|
||||
|
||||
console.log('\n=== 坑向事件 ===');
|
||||
{
|
||||
const L = new Level(LEVELS.find(l => l.id === 'r4'));
|
||||
const P = new Player(L);
|
||||
let died = 0; P.onDeath = () => died++;
|
||||
// 走到传送门 (r4: col 44, row 17)
|
||||
P.x = 44 * 32; P.y = 17 * 32;
|
||||
for (let i = 0; i < 4; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
|
||||
console.log(` 传送门:玩家被送到 x=${P.x.toFixed(0)} (起点 x=${L.spawn.x})`);
|
||||
if (Math.abs(P.x - L.spawn.x) > 4) err('传送门未送回起点');
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
// 可达性求解器:验证每关终点 / 存档点 / 苹果能否到达
|
||||
// DJ=0,12,24 收紧二段跳时机;REL=0 禁用短跳 —— 检验是否只有"某一帧"能过
|
||||
import { setupGlobals, reach, makeTargets } from './lib-reach.mjs';
|
||||
setupGlobals();
|
||||
|
||||
const { LEVELS } = await import('../js/levels/index.js');
|
||||
|
||||
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
|
||||
const RELSET = (process.env.REL || '0,4,5,6,8,14').split(',').map(Number);
|
||||
|
||||
let fail = 0;
|
||||
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
|
||||
for (const d of LEVELS) {
|
||||
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
|
||||
let r;
|
||||
try {
|
||||
const { Level } = await import('../js/level.js');
|
||||
const L = new Level(d);
|
||||
const targets = makeTargets(L);
|
||||
r = reach(L, { DJSET, RELSET, targets });
|
||||
r.targets = targets;
|
||||
} catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
|
||||
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
|
||||
|
||||
const goal = r.targets.find(t => t.type === 'goal');
|
||||
const saves = r.targets.filter(t => t.type === 'save');
|
||||
const apples = r.targets.filter(t => t.type === 'apple');
|
||||
const okSave = saves.filter(s => s.hit).length;
|
||||
const okApple = apples.filter(a => a.hit).length;
|
||||
const badSave = saves.filter(s => !s.hit).map(s => `#${s.no ?? s.idx}`);
|
||||
const flag = goal && goal.hit ? ' ' : '✗';
|
||||
if (!goal || !goal.hit) fail++;
|
||||
if (badSave.length) fail++;
|
||||
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states})`);
|
||||
}
|
||||
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,86 @@
|
||||
// 单三角形刺:视觉与碰撞形状完全对应的单元测试
|
||||
// 验证 spikeTri 顶点、triRectOverlap 的正确性,以及 deadlyRect 对刺的判定。
|
||||
const noop = () => { };
|
||||
const ctx2d = new Proxy({
|
||||
canvas: { width: 800, height: 608 },
|
||||
measureText: () => ({ width: 12 }),
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: () => ({ data: [] }),
|
||||
}, { get(t, k) { return k in t ? t[k] : noop; }, set(t, k, v) { t[k] = v; return true; } });
|
||||
|
||||
globalThis.location = { search: '', href: 'http://localhost/', pathname: '/', reload: noop };
|
||||
Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, userAgent: 'node' }, configurable: true });
|
||||
globalThis.matchMedia = q => ({ matches: false, media: q, addEventListener: noop, removeEventListener: noop });
|
||||
globalThis.window = { addEventListener: noop, removeEventListener: noop, AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800, location: globalThis.location, navigator: globalThis.navigator, matchMedia: globalThis.matchMedia };
|
||||
globalThis.document = { getElementById: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop, toggle: noop }, addEventListener: noop, appendChild: noop, querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [] }), querySelector: () => ({ getContext: () => ctx2d }), querySelectorAll: () => [], createElement: () => ({ getContext: () => ctx2d, style: {}, classList: { add: noop, remove: noop } }), addEventListener: noop, body: {} };
|
||||
globalThis.localStorage = { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } };
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.alert = noop;
|
||||
|
||||
const { Level, T, spikeTri, triRectOverlap } = await import('../js/level.js');
|
||||
const { TILE } = await import('../js/util.js');
|
||||
|
||||
let fail = 0;
|
||||
const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
|
||||
const near = (a, b, e = 1e-6) => Math.abs(a - b) < e;
|
||||
|
||||
console.log('=== spikeTri 顶点(与 drawSpike 一致)===');
|
||||
const s = TILE;
|
||||
{
|
||||
const tr = spikeTri(T.SPIKE_U, 0, 0);
|
||||
ok(near(tr.ax, s / 2) && near(tr.ay, 0) && near(tr.bx, 0) && near(tr.by, s) && near(tr.cx, s) && near(tr.cy, s), 'SPIKE_U:尖在顶中,底在底部');
|
||||
const tr2 = spikeTri(T.SPIKE_D, 0, 0);
|
||||
ok(near(tr2.ax, s / 2) && near(tr2.ay, s) && near(tr2.bx, 0) && near(tr2.by, 0), 'SPIKE_D:尖在底中,底在顶部');
|
||||
const tr3 = spikeTri(T.SPIKE_L, 0, 0);
|
||||
ok(near(tr3.ax, 0) && near(tr3.ay, s / 2) && near(tr3.bx, s) && near(tr3.by, 0) && near(tr3.cx, s) && near(tr3.cy, s), 'SPIKE_L:尖在左中,底在右侧');
|
||||
const tr4 = spikeTri(T.SPIKE_R, 0, 0);
|
||||
ok(near(tr4.ax, s) && near(tr4.ay, s / 2) && near(tr4.bx, 0) && near(tr4.by, 0), 'SPIKE_R:尖在右中,底在左侧');
|
||||
}
|
||||
|
||||
console.log('=== triRectOverlap 基本几何 ===');
|
||||
{
|
||||
// 上刺三角形:A(16,0) B(0,32) C(32,32)(TILE=32)
|
||||
const A = spikeTri(T.SPIKE_U, 0, 0);
|
||||
// 完全在三角形内部的矩形
|
||||
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 14, 20, 4, 4), '三角形内部矩形 → 相交');
|
||||
// 在三角形“上方窄尖之外”的矩形(顶部左侧,AABB 内但三角外)
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 1, 4, 4), '尖顶外侧矩形(AABB 内、三角外)→ 不相交');
|
||||
// 完全在三角形之外(远离)
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 100, 100, 4, 4), '远处矩形 → 不相交');
|
||||
// AABB 都不重叠
|
||||
ok(!triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 50, 0, 4, 4), 'AABB 不重叠 → 不相交');
|
||||
// 底边左角(上刺底部很宽)——应当相交
|
||||
ok(triRectOverlap(A.ax, A.ay, A.bx, A.by, A.cx, A.cy, 1, 28, 4, 4), '上刺底左角矩形 → 相交(形状精确:底宽尖窄)');
|
||||
}
|
||||
|
||||
console.log('=== deadlyRect 集成(上刺占一格)===');
|
||||
{
|
||||
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||
const L = new Level(data);
|
||||
ok(L.tiles[1][1] === T.SPIKE_U, '网格解析:中心格子为 SPIKE_U');
|
||||
const cx = 1 * s, cy = 1 * s; // 刺格像素 [32,32]-[64,64]
|
||||
// 顶部左侧(尖窄处,三角外)→ 不致死
|
||||
ok(!L.deadlyRect(cx + 1, cy + 1, 4, 4), '上刺尖顶外侧的碰撞盒 → 不致死');
|
||||
// 底部中央(基底宽处)→ 致死
|
||||
ok(L.deadlyRect(cx + 13, cy + 26, 8, 4), '上刺底部中央的碰撞盒 → 致死');
|
||||
// 底左角 → 致死
|
||||
ok(L.deadlyRect(cx + 1, cy + 26, 6, 4), '上刺底左角的碰撞盒 → 致死(形状精确)');
|
||||
// 相邻空格(格外)→ 不致死
|
||||
ok(!L.deadlyRect(0, 0, 11, 21), '完全在空地上的碰撞盒 → 不致死');
|
||||
}
|
||||
|
||||
console.log('=== 形状精确性对比:旧矩形近似会把底左角判为安全,现在判为致死 ===');
|
||||
{
|
||||
// 旧 spikeBox 对上刺 x+6..x+10, y+8.. :底左角 (cx+1,cy+26) 落在 x+6 之外,旧逻辑安全;
|
||||
// 新三角在底部很宽,应致死。下面断言新逻辑与“旧近似矩形”在该点结论相反(证明形状更精确)。
|
||||
const data = { name: 't', width: 3, grid: ['###', '#^#', '###'] };
|
||||
const L = new Level(data);
|
||||
const cx = s, cy = s;
|
||||
const oldSafe = (cx + 1 < cx + 6); // 旧矩形左界 x+6,该点在其左 → 旧逻辑“安全”
|
||||
const newDeadly = L.deadlyRect(cx + 1, cy + 26, 3, 3);
|
||||
ok(oldSafe && newDeadly, '底左角:旧矩形近似判安全,新三角判致死(符合“底宽尖窄”的真实形状)');
|
||||
}
|
||||
|
||||
console.log(fail === 0 ? '\n✅ 单三角形刺(视觉=碰撞)全部断言通过' : `\n❌ 失败 ${fail} 项`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user