This commit is contained in:
cancerai committed 2026-10-20 22:21:26 +08:00
commit 234e769f4d
34 files changed
+4667

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
# 2026-10-20
## 项目:IWBT C 同人平台跳跃游戏(网站)
从零搭建 `/Users/cancerai/tmp/iwanna`,纯前端无依赖(原生 ES modules + Canvas 2D),致敬 I Wanna Be The Guy / I Wanna Be The Creator。
### 架构
- `index.html` + `css/style.css` + `js/`
- `js/util.js` 常量(TILE=32,房间 800x608)
- `js/input.js` IWBT 键位(Shift/Space/Z 跳,X 射击,R 重开,Esc 暂停)
- `js/player.js` 手感物理:**重力 0.4、跳 8.5、二段跳 7.6、水平 3、最大下落 9、松手即停无惯性、松跳削减高度**
- `js/level.js` ASCII 网格解析 → tiles + 实体 + 事件系统(坑向触发器)
- `js/boss.js` 三阶段 BOSS(guy / mecha / devil),BOSS 贴地活动以保证站地平射可命中
- `js/camera.js` 跟随 / 锁定 / 缩放(「摄像头」道具效果)
- `js/levels/{tutorial,jump,endurance,boss,troll}.js` 共 **17 关**(教学2 + 跳刺5 + 耐久3 + BOSS3 + 坑向4)
- `js/editor.js` 自制关卡编辑器(localStorage 存档、导入导出 JSON)
- `js/save.js` localStorage 通关记录
### 关卡 ASCII 符号约定
`#@=M` 方块变体、`%` 冰、`~` 水、`-` 单向台、`V` 消失块、`*` 美味水果(即死)、
`^v<>` 四向刺、`S` 出生、`G` 存档、`X` 终点、`P` 手枪、`F` 苹果、`J` 跳刷新、`W` 传送门、
`T` 提示牌、`O` 炮台、`D` 掉落刺、`C` 摄像头区、`B` BOSS、`!` 事件触发器
### 关键设计约束(改关卡时必须遵守)
- 单次跳跃可上 **2 格(64px)**,二段跳可上 **3 格(96px)**,理论极限 ~5 格
- 平跳水平约 **4 格(128px)**,二段跳约 **6 格**
- 平台所在行 row R,玩家站立的台面高度 = `R * 32`
- 相邻平台垂直差 ≤ 96px、水平间隙 ≤ 96px 是安全设计区间
- 刺的碰撞盒比格子小(宽容判定)
### 测试工具(tools/)
- `check.mjs` 关卡格式校验(行宽/字符/S/X 数量/炮台·传送门·BOSS 配置数量一致)
- `smoke.mjs` 无头物理冒烟(构造全部关卡、模拟输入、死亡重生、BOSS 受伤、传送门)
- `dom-smoke.mjs` mock DOM 跑通 main.js 入口
- **`solve.mjs` 可达性求解器**:用真实物理常量做 BFS(走/落/跳×二段跳时机/空中转向),
验证终点与每个存档点可达。水当作可站立面、移动平台整条路径当桥(乐观)。
**改关卡后必跑**,否则很容易做出无法通关的图。
运行:`node tools/xxx.mjs`
### 第二轮改造(用户反馈后)
- 删除主菜单「开始游戏」按钮(点了没反应),只保留「关卡选择」进游戏
- 新增 `js/levels/kit.js`:`G(w,rows)` 自动补宽并把最后一列设为墙、`rep/E/plat/ent/flr` 辅助,
**写关卡不要再手工数点**,用这些函数拼行
- 关卡全面加长加大:跳刺 5→**10** 关(w 44~80、h 19~32),耐久 3→4 关(e1 100 格 / e4 120 格),
共 **23** 关;BOSS 血量上调(guy 72 / mecha 96 / devil 128,阶段按血量比例触发不受影响)
- `js/level.js` 掉落刺落地后 80 帧自动消失(否则会永久堵死落脚点)
### 第三轮:修存档点「没用」的 bug
根因:**R 键语义错**。IWBT 里 R = 自杀并回到最近存档点,但原实现把 R 绑成"整关重开"
(`startLevel` 重建 Level+Player,`savePoint` 清零)。玩家死了本能按 R → 存档进度全丢 → 存档点形同虚设。
修复与改动:
- `js/input.js`:`restart:['KeyR']` → `suicide:['KeyR']` + `restart:['Backspace']`
- `js/main.js`:新增 `suicide()`(调 `player.kill()`,死亡数+1,自动回存档点);暂停菜单按钮 = 整关重开
- 重生后 `cam.snapTo()` 立即对准(原来镜头慢慢 lerp,看着像没回去)
- `js/player.js`:存档点判定盒外扩 6px;激活时 toast「存档点 n/N 已激活」;respawn 加防卡墙顶出
- `js/level.js`:存档点编号 `e.no` + `totalSaves`;`js/ui.js` + HUD 新增 `存档 n/N` 指示
- `js/sprites.js`:未激活存档点加呼吸光环、颜色提亮(原来 `#5d4a72` 太暗看不清)
- `js/levels/jump.js` j2:存档点 (6,22) 悬空导致重生即坠落,移到 (15,22) 平台上
新增测试:`tools/save-test.mjs`(存档点激活+重生全关卡)、`tools/key-test.mjs`(R/Backspace 语义回归)、
`tools/save-diag.mjs`(存档点放置环境诊断)、`tools/bot.mjs`(真实物理机器人试玩)
### 第四轮:跳刺关全面加大加难 + 去掉存档点 toast
- 去掉「存档点 n/N 已激活」toast(user 要求);HUD 的 `存档 n/N` 指示保留
- `js/levels/kit.js` 新增 **`Grid` 网格构建器**(`.rect/.hline/.vline/.border/.out()`)和 `chain()`。
**大地图一律用 Grid 写,别再拼字符串**——104~150 宽的地图拼字符串必然错列
- 跳刺 10 关全部重写,尺寸 68×52 ~ 150×40(原来最大 80×32):
- j1 104×20 刺海长廊+竖井收尾 / j2 68×52 高塔 / j3 140×22 一格宽钢丝(顶刺)+竖井
- j4 130×24 冰面+冰柱+竖井 / j5 120×24 移动平台+炮台+竖井 / j6 132×34 双层层+竖井
- j7 72×52 掉落刺高塔 / j8 130×26 冰→塌陷浮桥→钢丝→单向台上行 / j9 128×34 三层+苹果
- j10 150×40 冰→钢丝→移动平台→竖井→钢丝
- 存档点压到 3~7 个(原 4~7 且关卡短一半),坑宽 4~6 格,落脚点 1~2 格
- 新增工具:`tools/reach-diag.mjs`(按行打印可达 x 范围,定位断连)、
`tools/jumpdist.mjs`(用真实 Player 测跳跃距离)、`tools/bot.mjs`、`tools/save-diag.mjs`
- `solve.mjs` 状态上限 6000→40000,并支持 `DJ=0,12,24` 环境变量收紧二段跳时机做鲁棒性检查
### 关键物理实测(真实引擎,tools/jumpdist.mjs)
单跳水平 **138px(4.3 格)**;二段跳随时机 156 / 183 / 207 / 225 / **237px(7.4 格)**。
求解器按 127 / 206 算,**比真实引擎更保守 → 求解器判可达即真人必可过**。
### 天花板刺摆放规则(踩过的坑)
- 平台行 P,天花板刺放 **P-5**:允许完整单跳、二段跳必死 → 该段坑宽只能 ≤ 4 格
- P-3 只允许 rise<65px 的小跳;P-7 以外基本无效
- 竖井/塔的入口、转向处**不能放侧壁刺**,否则跳跃弧线正好撞上(j3/j4 都栽过)
- 走廊尽头接竖井时,底座与走廊末块留 **3 格空隙、高差 2 行** 最稳
### 运行方式
ES modules 需要 HTTP:`python3 -m http.server 8123`(已提供 `启动游戏.command` 双击脚本)
+180
View File
@@ -0,0 +1,180 @@
: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%}
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}
.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}
.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)}
.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}
.help-body{display:flex;gap:18px;flex-wrap:wrap;overflow-y:auto}
.help-col{flex:1;min-width:240px}
.help-col ul{list-style:none;line-height:1.75}
.help-col li{color:var(--dim);font-size:.92em}
.help-col li b{color:var(--fg)}
.help-tip{border-top:1px solid var(--line);padding-top:8px;color:var(--gold);font-size:.85em}
.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 .sw{width:14px;height:14px;border:1px solid #000;flex:none}
.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}
.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}
canvas#ed-canvas{
width:100%;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)}
#ed-json{flex:1;min-height:80px;background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:.7em;padding:4px;resize:none}
::-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}
}
+176
View File
@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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="help">操作说明</button>
<button class="btn small" data-act="wipe">清除存档</button>
</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>
<button class="btn tiny" data-act="back">返回</button>
</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>
<!-- ===== HELP ===== -->
<div class="screen hidden" id="scr-help">
<div class="panel">
<div class="panel-head">
<h2>操作说明</h2>
<button class="btn tiny" data-act="back">返回</button>
</div>
<div class="help-body">
<div class="help-col">
<h3>按键</h3>
<ul>
<li><b>← →</b> / <b>A D</b> — 移动(IWBT 手感:松手即停,无惯性)</li>
<li><b>Shift</b> / <b>空格</b> / <b>Z</b> — 跳跃(空中再按一次=二段跳)</li>
<li><b>X</b> / <b>J</b> — 射击(需先拾取手枪)</li>
<li><b>R</b> — 自杀,回到<b>最近的存档点</b>(死亡数 +1)</li>
<li><b>Backspace</b> — 整关从头再来(会清空存档点进度)</li>
<li><b>Esc</b> — 暂停 <b>↑ ↓</b> — 水中上浮 / 下潜</li>
</ul>
</div>
<div class="help-col">
<h3>规则与道具</h3>
<ul>
<li><b>刺</b>(灰白三角)碰到即死,回最近存档点</li>
<li><b>存档点</b>(紫色果实)触碰后在此复活</li>
<li><b>手枪</b>(灰色枪)拾取后获得射击能力,子弹可击杀敌人与 BOSS</li>
<li><b>摄像头</b>(黑色镜头)进入区域会锁定 / 缩放视角——坑向关卡的元凶</li>
<li><b>美味水果</b>(红苹果)看着像补给,其实是即死陷阱</li>
<li><b>传送门</b>(紫色漩涡)成对传送,坑向关卡会把你送回起点</li>
<li><b>跳跃刷新器</b>(绿色箭头)空中触碰可重置二段跳</li>
<li><b>消失方块</b>踩上后会塌,<b>冰面</b>会打滑,<b>水</b>中可游泳</li>
</ul>
</div>
</div>
<div class="help-tip">提示:死亡是这个游戏的一部分。别放弃,IWBT 玩家的平均死亡数是三位数。</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>
<!-- ===== 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 STAGE">
<select id="ed-type">
<option value="jump">跳刺</option>
<option value="endur">耐久</option>
<option value="boss">BOSS</option>
<option value="troll">坑向</option>
</select>
<input id="ed-w" type="number" value="25" min="12" max="120" title="宽度(格)">
<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-test">试玩</button>
<button class="btn tiny go" data-act="ed-save">保存</button>
</div>
<canvas id="ed-canvas" width="800" height="608"></canvas>
<div class="editor-tip">左键放置 / 右键擦除,滚轮平移。至少要有一个 <b>S</b>(出生点) 和一个 <b>X</b>(终点)。</div>
</div>
<div class="editor-side">
<h3>我的关卡</h3>
<div id="my-levels"></div>
<h3 style="margin-top:14px">导入 / 导出</h3>
<textarea id="ed-json" placeholder="粘贴关卡 JSON 后点导入"></textarea>
<button class="btn tiny" data-act="ed-import">导入</button>
<button class="btn tiny" data-act="ed-export">导出选中</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>
+71
View File
@@ -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
View File
@@ -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 };
+90
View File
@@ -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);
}
}
+245
View File
@@ -0,0 +1,245 @@
// 简易关卡编辑器
import { TILE } from './util.js';
import { Save } from './save.js';
import { makeCustomLevel } from './levels/index.js';
const $ = s => document.querySelector(s);
const H_ROWS = 19;
const PALETTE = [
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
['^', '上刺', '#c8c8d8'], ['v', '下刺', '#a0a0b4'], ['<', '左刺', '#9090a8'], ['>', '右刺', '#9090a8'],
['*', '美味水果', '#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'],
];
export const Editor = {
api: {},
map: null, w: 25, sel: '#',
scroll: 0, drag: 0, cur: null,
canvas: null, ctx: 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 || 25));
this.renderMyLevels();
},
buildPalette() {
const p = $('#palette');
p.innerHTML = '';
PALETTE.forEach(([ch, label, col]) => {
const d = document.createElement('div');
d.className = 'pal' + (ch === this.sel ? ' on' : '');
d.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch}</span><span>${label}</span>`;
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 { tx, ty } = pos(e);
if (tx < 0 || ty < 0 || tx >= this.w || ty >= H_ROWS) return;
this.map[ty][tx] = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
this.draw();
};
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.w - 25) * TILE, this.scroll + Math.sign(e.deltaY) * TILE * 2));
this.draw();
}, { passive: false });
document.querySelector('[data-act="ed-resize"]').onclick = () => this.resize(+$('#ed-w').value || 25);
document.querySelector('[data-act="ed-fill"]').onclick = () => {
for (let x = 0; x < this.w; x++) this.map[H_ROWS - 1][x] = '#';
this.draw();
};
document.querySelector('[data-act="ed-clear"]').onclick = () => {
if (!confirm('清空当前编辑的关卡?')) return;
this.map = blank(this.w); this.draw();
};
document.querySelector('[data-act="ed-test"]').onclick = () => this.test();
document.querySelector('[data-act="ed-save"]').onclick = () => this.save();
document.querySelector('[data-act="ed-export"]').onclick = () => this.exportSel();
document.querySelector('[data-act="ed-import"]').onclick = () => this.importJSON();
},
open() {
if (!this.map) { this.w = +$('#ed-w').value || 25; this.map = blank(this.w); }
this.draw();
this.renderMyLevels();
},
resize(w) {
w = Math.max(12, Math.min(120, w || 25));
const old = this.map;
this.w = w;
this.map = blank(w);
if (old) for (let y = 0; y < H_ROWS; y++) for (let x = 0; x < Math.min(w, old[0].length); x++) this.map[y][x] = old[y][x];
$('#ed-w').value = w;
this.draw();
},
draw() {
const ctx = this.ctx, c = this.canvas;
c.width = 800; c.height = H_ROWS * TILE;
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
ctx.save();
ctx.translate(-this.scroll, 0);
for (let y = 0; y < H_ROWS; y++) {
for (let x = 0; x < this.w; x++) {
const ch = this.map[y][x];
const px = x * TILE, py = y * TILE;
if (ch !== '.') {
const item = PALETTE.find(p => p[0] === ch);
ctx.fillStyle = item ? item[2] : '#555';
ctx.globalAlpha = ch === '.' ? 0.2 : 0.85;
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
ctx.globalAlpha = 1;
}
ctx.strokeStyle = 'rgba(255,255,255,.07)';
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
if (ch !== '.') {
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
}
}
}
ctx.restore();
},
// ---------- 数据 ----------
buildData() {
return {
name: ($('#ed-name').value || 'MY STAGE').toUpperCase(),
type: $('#ed-type').value,
width: this.w,
grid: this.map.map(r => r.join('')),
bg: 'custom',
hint: '自制关卡',
messages: ['自制关卡'],
};
},
validate() {
const g = this.map.map(r => r.join(''));
const s = g.join('').split('S').length - 1;
const x = g.join('').split('X').length - 1;
if (s !== 1) return '必须且只能有一个 S(出生点),当前 ' + s + ' 个';
if (x < 1) return '至少要有一个 X(终点)';
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; }
const lv = makeCustomLevel(this.buildData());
Save.addCustom(lv);
this.cur = lv.id;
this.renderMyLevels();
this.api.refresh?.();
},
exportSel() {
const list = Save.customs();
const lv = list.find(l => l.id === this.cur) || list[list.length - 1];
$('#ed-json').value = lv ? JSON.stringify(lv) : JSON.stringify(makeCustomLevel(this.buildData()));
},
importJSON() {
try {
const o = JSON.parse($('#ed-json').value);
if (!o.grid) throw new Error('缺少 grid');
this.w = o.width || o.grid[0].length;
$('#ed-w').value = this.w;
$('#ed-name').value = o.name || 'IMPORTED';
$('#ed-type').value = ['jump', 'endur', 'boss', 'troll'].includes(o.type) ? o.type : 'jump';
this.map = blank(this.w);
for (let y = 0; y < Math.min(H_ROWS, o.grid.length); y++) {
const line = o.grid[y];
for (let x = 0; x < Math.min(this.w, line.length); x++) this.map[y][x] = line[x] || '.';
}
this.draw();
} catch (e) { alert('导入失败:' + e.message); }
},
renderMyLevels() {
const box = $('#my-levels');
box.innerHTML = '';
const list = Save.customs();
if (!list.length) box.innerHTML = '<div style="color:#666;font-size:.8em">还没有保存的关卡</div>';
list.forEach((l, i) => {
const d = document.createElement('div');
d.className = 'my-lv' + (l.id === this.cur ? ' on' : '');
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}</span><span class="x">✕</span>`;
d.querySelector('span').onclick = () => {
this.cur = l.id;
this.load(l);
this.renderMyLevels();
};
d.querySelector('.x').onclick = (e) => {
e.stopPropagation();
if (!confirm('删除 ' + l.name + '?')) return;
Save.removeCustom(l.id);
this.renderMyLevels();
this.api.refresh?.();
};
box.appendChild(d);
});
},
load(l) {
this.w = l.width || l.grid[0].length;
$('#ed-w').value = this.w;
$('#ed-name').value = l.name || '';
$('#ed-type').value = ['jump', 'endur', 'boss', 'troll'].includes(l.type) ? l.type : 'jump';
this.map = blank(this.w);
for (let y = 0; y < Math.min(H_ROWS, l.grid.length); y++) {
const line = l.grid[y];
for (let x = 0; x < Math.min(this.w, line.length); x++) this.map[y][x] = line[x] || '.';
}
this.draw();
},
};
function blank(w) {
return Array.from({ length: H_ROWS }, () => Array.from({ length: w }, () => '.'));
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+65
View File
@@ -0,0 +1,65 @@
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
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 down = new Set(); // 当前按住的原始 code
const now = {}; // action 是否按下
const prev = {}; // 上一帧
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; }
now[act] = Input.enabled ? v : false;
}
}
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(); });
// 初始化
for (const act in BIND) { now[act] = false; prev[act] = false; }
export const ACTIONS = Object.keys(BIND);
+620
View File
@@ -0,0 +1,620 @@
// 关卡解析与运行时: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 等)
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] || {};
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
e.to = cfg.to || null; // {x,y} 绝对像素 / 'spawn'
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 - 7, y: sp.y + sp.h - 18 }
: (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;
}
/** 死亡后的软重置:保留存档点与已拾取道具,其余复位 */
softReset() {
this.vanish.clear();
if (this.vanishT) this.vanishT.clear();
this.bullets.length = 0;
this.ebullets.length = 0;
this.particles?.clear();
this.shake = 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;
}
}
// ---------------- 查询 ----------------
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 r = spikeBox(t, tx, ty);
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) return true;
}
}
for (const e of this.entities) {
if (e.type === 'dropspike' && e.falling) {
const r = spikeBox(T.SPIKE_U, e.x / TILE, e.y / TILE);
if (x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) 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;
// 移动平台
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);
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);
}
}
}
function spikeBox(t, tx, ty) {
const x = tx * TILE, y = ty * TILE;
if (t === T.SPIKE_U) return { x: x + 4, y: y + 11, w: TILE - 8, h: TILE - 12 };
if (t === T.SPIKE_D) return { x: x + 4, y: y + 2, w: TILE - 8, h: TILE - 11 };
if (t === T.SPIKE_L) return { x: x + 3, y: y + 4, w: TILE - 12, h: TILE - 8 };
return { x: x + 9, y: y + 4, w: TILE - 12, h: TILE - 8 };
}
export { spikeBox };
+63
View File
@@ -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
]),
},
];
+131
View File
@@ -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 },
],
},
];
+26
View File
@@ -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);
}
+368
View File
@@ -0,0 +1,368 @@
// 跳刺(Needle)关卡 —— 10 关
// 设计基调:地图尽可能大、落脚点尽可能小、存档点尽可能稀疏。
// 物理上限(改动前务必记住):
// 平跳水平 ≈ 4 格(127px),二段跳 ≈ 6 格(206px)
// 单跳上升 ≈ 2.8 格(90px),二段跳上升 ≈ 5 格(162px)
// 天花板刺放在「平台行 -5」:允许完整单跳、二段跳必死 → 只能走 4 格以内的坑
import { Grid } from './kit.js';
/** 落脚台序列:run(起始列, 起始宽, [[间隙, 台宽], ...]) => [{x,w}, ...] */
function run(x0, w0, specs) {
const out = [{ x: x0, w: w0 }];
let x = x0 + w0;
for (const [g, w] of specs) { x += g; out.push({ x, w }); x += w; }
return out;
}
const putAll = (m, row, pads, ch = '#') => pads.forEach(p => m.rect(p.x, row, p.w, 1, ch));
/** 竖井:在 [x0,x1] 列范围内交错上行,每级上升 2 格、横移 step 列 */
function shaft(m, rowBottom, rowTop, x0, x1, step = 6, w = 2) {
const out = [];
let row = rowBottom, x = x0, dir = 1;
while (row >= rowTop) {
m.rect(x, row, w, 1, '#');
out.push({ x, row });
row -= 2;
const nx = x + dir * step;
if (nx < x0 || nx + w - 1 > x1) dir = -dir;
else x = nx;
}
return out;
}
export default [
// ================= 1 =================
{
id: 'j1', name: 'SPIKE ALLEY', type: 'jump', diff: 2, bg: 'jump',
width: 104, hint: '刺海长廊。落脚点越来越小,坑越来越宽。104 格,只有三个存档点。',
grid: (() => {
const W = 104, H = 20, R = 17;
const m = new Grid(W, H);
m.border();
m.hline(18, 1, W - 2, '^'); // 刺海
const pads = run(2, 6, [
[4, 2], [4, 2], [4, 2], [5, 2], [5, 1],
[4, 1], [5, 1], [6, 1], [5, 2], [4, 1],
]);
putAll(m, R, pads);
// 尽头接竖井,终点在半空
const sp = shaft(m, 15, 5, 72, 96, 6, 2);
for (let r = 7; r < 16; r += 3) { m.set(70, r, '>'); m.set(98, r, '<'); }
const top = sp[sp.length - 1];
m.set(3, R - 1, 'S');
m.set(pads[3].x, R - 1, 'G');
m.set(pads[7].x, R - 1, 'G');
m.set(sp[1].x, sp[1].row - 1, 'G');
m.set(sp[3].x, sp[3].row - 1, 'G');
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 2 =================
{
id: 'j2', name: 'VERTICAL HELL', type: 'jump', diff: 3, bg: 'jump',
width: 68, hint: '68×52 的高塔。每级上升 2 格、横移 6 格,头顶还悬着掉刺。',
grid: (() => {
const W = 68, H = 52;
const m = new Grid(W, H);
m.border();
m.hline(H - 2, 1, W - 2, '^');
let row = H - 3, x = 4, dir = 1;
const pads = [];
while (row >= 5) {
m.rect(x, row, 2, 1, '#');
pads.push({ x, row });
row -= 2;
const nx = x + dir * 6;
if (nx + 1 > W - 8) dir = -1; else if (nx < 4) dir = 1; else x = nx;
}
for (let r = 10; r < H - 4; r += 3) { m.set(1, r, '>'); m.set(W - 2, r, '<'); }
pads.forEach((p, i) => { if (i % 3 === 1 && p.row - 6 > 2) m.set(p.x, p.row - 6, 'D'); });
pads.forEach((p, i) => { if (i > 0 && i % 6 === 0) m.set(p.x, p.row - 1, 'G'); });
m.set(5, H - 4, 'S');
const top = pads[pads.length - 1];
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 3 =================
{
id: 'j3', name: 'TIGHTROPE', type: 'jump', diff: 4, bg: 'jump',
width: 140, hint: '140 格的一格宽钢丝,头顶全是刺:只能单跳,二段跳必死。三个存档点。',
grid: (() => {
const W = 140, H = 22, R = 17;
const m = new Grid(W, H);
m.border();
m.hline(18, 1, W - 2, '^');
const pads = run(2, 5, Array.from({ length: 14 }, () => [4, 1]));
putAll(m, R, pads);
m.hline(R - 5, 8, 78, 'v'); // 天花板刺
const sp = shaft(m, 15, 5, 80, 108, 6, 2);
[7, 10].forEach(r => m.set(78, r, '>'));
[7, 10, 13].forEach(r => m.set(110, r, '<'));
const top = sp[sp.length - 1];
m.set(3, R - 1, 'S');
m.set(pads[4].x, R - 1, 'G');
m.set(pads[10].x, R - 1, 'G');
m.set(sp[1].x, sp[1].row - 1, 'G');
m.set(sp[3].x, sp[3].row - 1, 'G');
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 4 =================
{
id: 'j4', name: 'ICE NEEDLES', type: 'jump', diff: 4, bg: 'jump',
width: 130, hint: '前半段冰面打滑、坑有五格宽;后半段一格宽冰柱悬在刺海上——头顶有刺,只能单跳。',
grid: (() => {
const W = 130, H = 24, R = 19;
const m = new Grid(W, H);
m.border();
m.hline(20, 1, W - 2, '^');
m.rect(2, R, 6, 1, '%');
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 6 }, { x: 46, w: 5 }]
.forEach(s => m.rect(s.x, R, s.w, 1, '%'));
const pads = run(56, 4, Array.from({ length: 8 }, () => [4, 1]));
putAll(m, R, pads);
m.hline(R - 5, 56, 100, 'v');
const sp = shaft(m, 17, 5, 103, 126, 6, 2);
[8, 11].forEach(r => m.set(101, r, '>'));
[8, 11, 14, 17].forEach(r => m.set(128, r, '<'));
const top = sp[sp.length - 1];
m.set(4, R - 1, 'S');
m.set(14, R - 1, 'G');
m.set(36, R - 1, 'G');
m.set(pads[4].x, R - 1, 'G');
m.set(sp[2].x, sp[2].row - 1, 'G');
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 5 =================
{
id: 'j5', name: 'THE GAUNTLET', type: 'jump', diff: 5, bg: 'jump',
width: 120, hint: '两格宽的移动平台横跨刺海,旁边还有炮台点名。等它靠岸,别抢。',
grid: (() => {
const W = 120, H = 24, R = 19;
const m = new Grid(W, H);
m.border();
m.hline(20, 1, W - 2, '^');
[{ x: 2, w: 6 }, { x: 24, w: 3 }, { x: 46, w: 3 }, { x: 68, w: 3 }, { x: 90, w: 3 }]
.forEach(s => m.rect(s.x, R, s.w, 1, '#'));
[35, 57, 79].forEach(x => m.rect(x, R, 1, 1, '#'));
[30, 52, 74, 86].forEach(x => m.set(x, R - 3, 'O'));
const sp = shaft(m, 17, 5, 96, 116, 6, 2);
for (let r = 9; r < 18; r += 3) { m.set(94, r, '>'); m.set(118, r, '<'); }
const top = sp[sp.length - 1];
m.set(4, R - 1, 'S');
m.set(25, R - 1, 'G');
m.set(47, R - 1, 'G');
m.set(69, R - 1, 'G');
m.set(sp[2].x, sp[2].row - 1, 'G');
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
movers: [
{ x: 10, y: 19, w: 2, h: 1, path: [[10, 19], [22, 19]], speed: 1.5 },
{ x: 29, y: 19, w: 2, h: 1, path: [[29, 19], [44, 19]], speed: 1.7 },
{ x: 51, y: 19, w: 2, h: 1, path: [[51, 19], [66, 19]], speed: 1.9 },
{ x: 73, y: 19, w: 2, h: 1, path: [[73, 19], [88, 19]], speed: 2.1 },
],
turrets: [
{ dir: 'left', period: 70, speed: 2.6, offset: 0 },
{ dir: 'left', period: 62, speed: 2.8, offset: 20 },
{ dir: 'left', period: 58, speed: 3.0, offset: 40 },
{ dir: 'left', period: 54, speed: 3.2, offset: 10 },
],
},
// ================= 6 =================
{
id: 'j6', name: 'NEEDLE CATHEDRAL', type: 'jump', diff: 5, bg: 'jump',
width: 132, hint: '下层长廊 → 竖井爬 9 层 → 上层长廊横穿回去。132×34,五个存档点。',
grid: (() => {
const W = 132, H = 34;
const m = new Grid(W, H);
m.border();
m.hline(H - 2, 1, W - 2, '^');
// --- 下层长廊 row 29(列 2..86)---
const low = run(2, 6, [
[4, 2], [4, 2], [4, 1], [5, 1], [4, 2], [5, 1],
[4, 1], [5, 2], [4, 1], [5, 1], [4, 2], [5, 1],
[4, 3],
]);
putAll(m, 29, low);
m.hline(30, 1, W - 2, '^');
// --- 竖井:列 88..120,row 27 → 11 ---
const sp = shaft(m, 27, 11, 88, 120, 6, 2);
for (let r = 12; r < 28; r += 4) { m.set(86, r, '>'); m.set(122, r, '<'); }
// --- 上层长廊 row 11,从竖井顶往左横穿 ---
const up = [];
let x = sp[sp.length - 1].x;
while (x > 6) { m.rect(x, 11, 2, 1, '#'); up.push(x); x -= 6; }
m.hline(12, 6, 84, '^'); // 上层地板刺(避开竖井列)
m.hline(6, 6, 84, 'v'); // 上层天花板刺
m.rect(2, 11, 6, 1, '#');
m.set(4, 10, 'X');
m.set(4, 28, 'S');
m.set(low[3].x, 28, 'G');
m.set(low[7].x, 28, 'G');
m.set(low[11].x, 28, 'G');
m.set(sp[3].x, sp[3].row - 1, 'G');
m.set(up[3], 10, 'G');
m.set(up[8], 10, 'G');
return m.out();
})(),
},
// ================= 7 =================
{
id: 'j7', name: 'SKY SPIKES', type: 'jump', diff: 5, bg: 'jump',
width: 72, hint: '72×52。两格宽的平台、六格横移,每两级就有一个平台只有一格宽,两边墙上全是刺。',
grid: (() => {
const W = 72, H = 52;
const m = new Grid(W, H);
m.border();
m.hline(H - 2, 1, W - 2, '^');
let row = H - 3, x = 4, dir = 1;
const pads = [];
while (row >= 5) {
const w = (pads.length % 4 === 3) ? 1 : 2;
m.rect(x, row, w, 1, '#');
pads.push({ x, row });
row -= 2;
const nx = x + dir * 6;
if (nx + 1 > W - 8) dir = -1; else if (nx < 4) dir = 1; else x = nx;
}
for (let r = 8; r < H - 4; r += 2) { m.set(1, r, '>'); m.set(W - 2, r, '<'); }
pads.forEach((p, i) => { if (i % 2 === 0 && p.row - 5 > 2) m.set(p.x, p.row - 5, 'D'); });
pads.forEach((p, i) => { if (i > 0 && i % 5 === 0) m.set(p.x, p.row - 1, 'G'); });
m.set(5, H - 4, 'S');
const top = pads[pads.length - 1];
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 8 =================
{
id: 'j8', name: 'TRIPLE THREAT', type: 'jump', diff: 5, bg: 'jump',
width: 130, hint: '冰 → 24 格长的塌陷浮桥 → 一格宽钢丝 → 单向平台直上。四连击。',
grid: (() => {
const W = 130, H = 26, R = 21;
const m = new Grid(W, H);
m.border();
m.hline(22, 1, W - 2, '^');
// 1) 冰面
m.rect(2, R, 6, 1, '%');
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 5 }].forEach(s => m.rect(s.x, R, s.w, 1, '%'));
// 2) 消失浮桥
m.rect(41, R, 24, 1, 'V');
// 3) 一格宽钢丝(顶刺,只能单跳)
const pads = run(69, 3, [[4, 1], [4, 1], [4, 1], [4, 1], [4, 1]]);
putAll(m, R, pads);
m.hline(R - 5, 69, 92, 'v');
// 4) 单向平台直上
const sp = shaft(m, 19, 7, 96, 124, 6, 2);
const top = sp[sp.length - 1];
m.set(4, R - 1, 'S');
m.set(14, R - 1, 'G');
m.set(36, R - 1, 'G');
m.set(pads[2].x, R - 1, 'G');
m.set(sp[2].x, sp[2].row - 1, 'G');
m.set(top.x, top.row - 1, 'X');
return m.out();
})(),
},
// ================= 9 =================
{
id: 'j9', name: 'CHERRY BLOSSOM', type: 'jump', diff: 5, bg: 'jump',
width: 128, hint: '苹果挂在钢丝之间的空中。不拿能通关,拿了才算完整——你会为它死很多次。',
grid: (() => {
const W = 128, H = 34;
const m = new Grid(W, H);
m.border();
m.hline(H - 2, 1, W - 2, '^');
// 下层长廊 row 29(列 2..84)
const low = run(2, 6, [
[4, 2], [5, 1], [4, 1], [5, 2], [4, 1], [5, 1], [4, 2], [5, 1], [4, 1],
[5, 2], [4, 1], [5, 2], [4, 1],
]);
putAll(m, 29, low);
m.hline(30, 1, W - 2, '^');
// 竖井:列 88..118,row 27 → 13
const sp = shaft(m, 27, 13, 88, 118, 6, 2);
for (let r = 14; r < 28; r += 4) { m.set(86, r, '>'); m.set(120, r, '<'); }
// 上层钢丝 row 11,从竖井顶往左
const up = [];
let x = sp[sp.length - 1].x;
while (x > 8) { m.rect(x, 11, 1, 1, '#'); up.push(x); x -= 4; }
m.hline(12, 8, 84, '^');
m.hline(6, 8, 84, 'v');
m.rect(2, 11, 6, 1, '#');
// 苹果挂在钢丝之间的空中(要跳过去才拿得到)
[3, 7, 11].forEach(i => { if (up[i]) m.set(up[i] - 2, 8, 'F'); });
m.set(4, 10, 'X');
m.set(4, 28, 'S');
m.set(low[3].x, 28, 'G');
m.set(low[6].x, 28, 'G');
m.set(sp[3].x, sp[3].row - 1, 'G');
m.set(up[4], 10, 'G');
m.set(up[9], 10, 'G');
return m.out();
})(),
},
// ================= 10 =================
{
id: 'j10', name: 'FINAL NEEDLE', type: 'jump', diff: 5, bg: 'jump',
width: 150, hint: '150×40。冰 → 钢丝 → 移动平台 → 竖井 → 钢丝。跳刺的毕业考,祝你活着出去。',
grid: (() => {
const W = 150, H = 40;
const m = new Grid(W, H);
m.border();
m.hline(H - 2, 1, W - 2, '^'); // row 38 刺海
// 1) 冰面 row 35
m.rect(2, 35, 6, 1, '%');
[{ x: 13, w: 6 }, { x: 24, w: 6 }, { x: 35, w: 5 }, { x: 45, w: 5 }]
.forEach(s => m.rect(s.x, 35, s.w, 1, '%'));
m.hline(36, 1, W - 2, '^');
// 2) 钢丝 row 35(顶刺,只能单跳)
const w1 = run(54, 4, [[4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1]]);
putAll(m, 35, w1);
m.hline(30, 54, 92, 'v');
// 3) 移动平台跨刺海
m.rect(97, 35, 4, 1, '#');
m.rect(118, 35, 4, 1, '#');
// 4) 竖井 列 118..144,row 33 → 9
const sp = shaft(m, 33, 9, 118, 144, 6, 2);
for (let r = 10; r < 34; r += 4) { m.set(116, r, '>'); m.set(146, r, '<'); }
// 5) 上层钢丝 row 7,往左到终点
const w2 = [];
let x = sp[sp.length - 1].x - 2;
while (x > 8) { m.rect(x, 7, 1, 1, '#'); w2.push(x); x -= 4; }
m.hline(8, 6, 114, '^');
m.hline(2, 6, 114, 'v');
m.rect(2, 7, 6, 1, '#');
m.set(4, 6, 'X');
m.set(4, 34, 'S');
m.set(14, 34, 'G');
m.set(36, 34, 'G');
m.set(w1[3].x, 34, 'G');
m.set(98, 34, 'G');
m.set(sp[4].x, sp[4].row - 1, 'G');
m.set(w2[5], 6, 'G');
m.set(w2[12], 6, 'G');
return m.out();
})(),
movers: [
{ x: 104, y: 35, w: 2, h: 1, path: [[104, 35], [116, 35]], speed: 2.0 },
],
},
];
+72
View File
@@ -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;
}
+91
View File
@@ -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 }] },
],
},
];
+47
View File
@@ -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),
]),
},
];
+217
View File
@@ -0,0 +1,217 @@
// 入口:游戏循环 / 状态机
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 { sfx, unlockAudio, Audio2 } from './audio.js';
import { Editor } from './editor.js';
import { clamp } from './util.js';
const W = 800, H = 608;
const STEP = 1000 / 60;
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,
deaths: 0, ms: 0, hudTick: 0,
time: 0,
lastLevelId: null,
};
// ---------------- 关卡流程 ----------------
function startLevel(data, idx = -1) {
unlockAudio();
G.data = data; G.idx = idx; G.lastLevelId = data.id;
G.level = new Level(data);
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.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
G.deaths = 0; G.ms = 0; G.hudTick = 0;
G.state = 'playing';
UI.showGame();
UI.toast('');
if (data.hint) UI.toast(data.hint);
}
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
function restart() {
if (!G.data) return;
const d = G.deaths;
startLevel(G.data, G.idx);
G.deaths = d; // 死亡数不重置
}
/** 自杀(IWBT 的 R 键):死后自动回到最近的存档点 */
function suicide() {
if (!G.player || G.state !== 'playing') return;
G.player.kill();
}
function finish() {
if (G.state !== 'playing') return;
G.state = 'result';
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'); }
function toTitle() { G.state = 'menu'; UI.show('scr-title'); }
function pause() { if (G.state === 'playing') { G.state = 'paused'; UI.show('scr-pause'); } }
function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame(); } }
// ---------------- 更新 ----------------
function update() {
const L = G.level, 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;
L.update(1, P, G.cam);
for (const b of L.bosses) b.update(1, P);
P.update(1);
// 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去
if (wasDead && !P.dead) G.cam.snapTo(P, L);
G.cam.update(L, P, 1);
G.ms += STEP;
G.time++;
if (++G.hudTick % 5 === 0) {
UI.hud({
name: G.data.name, deaths: G.deaths, ms: G.ms,
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
});
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 (P.dead) {
ctx.fillStyle = 'rgba(200,0,40,.18)';
ctx.fillRect(0, 0, W, H);
}
} 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();
}
// ---------------- 启动 ----------------
Save.load();
Audio2.enabled = Save.data.audio !== false;
UI.init({
play: (lv) => startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)),
levels: toLevels,
help: () => UI.show('scr-help'),
editor: () => { G.state = 'editor'; UI.show('scr-editor'); Editor.open(); },
back: toTitle,
resume,
restart: () => { restart(); },
quit: toLevels,
retry: () => startLevel(G.data, G.idx),
next: nextLevel,
wipe: () => {
if (confirm('确定清除所有通关记录与自制关卡?')) { Save.wipe(); UI.renderLevels(); }
},
});
Editor.init({
startLevel: (data) => startLevel(data, -1),
refresh: () => UI.renderLevels(),
});
// 首次交互解锁音频
const unlock = () => { unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); };
addEventListener('pointerdown', unlock);
addEventListener('keydown', unlock);
UI.show('scr-title');
requestAnimationFrame(loop);
// 调试钩子
window.__IW = { G, LEVELS, Save, startLevel };
+44
View File
@@ -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;
}
}
+305
View File
@@ -0,0 +1,305 @@
// 玩家:还原 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.4,
MAXFALL: 9,
JUMP: 8.5,
DJUMP: 7.6,
HSPD: 3,
WATER_GRAV: 0.16,
WATER_FALL: 2.6,
WATER_RISE: 0.42,
ICE_ACC: 0.18,
ICE_FRIC: 0.07,
};
export class Player {
constructor(level) {
this.level = level;
this.w = 14; this.h = 18;
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.grounded = false;
this.facing = 1;
this.alive = true;
this.dead = false;
this.deadT = 0;
this.squash = 1;
this.inWater = false;
this.animT = 0;
this.shootCool = 0;
this.mover = null;
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; }
kill() {
if (this.dead || !this.alive) return;
this.dead = true; this.alive = false; this.deadT = 0;
this.vx = 0; this.vy = 0;
sfx.death();
this.level.particles.burst(this.cx, this.cy, {
n: 26, colors: ['#e03050', '#fff', '#ffc63a', '#8e2530'],
speed: 4.5, life: 44, size: 4, grav: 0.18,
});
this.level.shake = 9;
if (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.squash = 1; this.shootCool = 0;
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
let guard = 0;
while (L.solidRect(this.x, this.y, this.w, this.h) && guard++ < 48) this.y -= 2;
L.softReset();
}
// ---------------- 每帧更新 ----------------
update(dt) {
const L = this.level;
this.animT++;
if (this.shootCool > 0) this.shootCool--;
if (this.dead) {
if (++this.deadT > 34) this.respawn();
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;
if (onIce) {
// 冰面:打滑
if (target !== 0) this.vx += sgn(target) * PHY.ICE_ACC;
else {
if (Math.abs(this.vx) < PHY.ICE_FRIC) this.vx = 0;
else this.vx -= sgn(this.vx) * PHY.ICE_FRIC;
}
this.vx = clamp(this.vx, -PHY.HSPD * 1.35, PHY.HSPD * 1.35);
} else {
this.vx = target;
}
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;
} else {
if (Input.hit('jump')) {
if (this.grounded || this.jumps === 0) {
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
this.squash = 1.28;
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) {
this.vy = -PHY.DJUMP; this.jumps = 2;
this.squash = 1.22;
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;
this.vy = Math.min(this.vy + PHY.GRAV, PHY.MAXFALL);
}
// ---- 站在移动平台上则跟随 ----
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) {
if (wasAir) { this.squash = 0.72; }
this.jumps = 0;
}
this.squash += (1 - this.squash) * 0.22;
// ---- 边界 ----
this.x = clamp(this.x, 0, L.pxW - this.w);
if (this.y > L.pxH + 200) this.kill();
if (this.y < -300) { this.y = -300; this.vy = 0; }
// ---- 致死 ----
if (L.deadlyRect(this.x, this.y, this.w, this.h)) this.kill();
// ---- 射击 ----
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 };
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(); }
}
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 tgt = e.to === 'spawn' ? L.spawn
: e.to ? { x: e.to[0] * TILE, y: e.to[1] * 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) {
S.drawKid(ctx, this, this.animT);
}
}
+50
View File
@@ -0,0 +1,50 @@
// 本地存档:通关记录、最佳成绩、自制关卡
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;
},
removeCustom(id) {
this.data.customs = (this.data.customs || []).filter(l => l.id !== id);
this.flush();
},
setAudio(on) { this.data.audio = on; this.flush(); },
};
+366
View File
@@ -0,0 +1,366 @@
// 程序化像素绘制(无外部图片资源)
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: '#c8c8d8', spikeDark: '#5a5a6a',
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);
}
export function drawSpike(ctx, x, y, dir, t) {
const s = TILE;
const g = ctx.createLinearGradient(x, y, x, y + s);
// 底座
if (dir === 0) { // 朝上(在地面上)
px(ctx, x + 1, y + s - 7, s - 2, 7, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + s - 6, x + 7.5 + i * 9, y + 5, x + 12 + i * 9, y + s - 6);
px(ctx, x + 1, y + s - 2, s - 2, 2, '#2a2a34');
} else if (dir === 1) { // 朝下(天花板)
px(ctx, x + 1, y, s - 2, 7, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 3 + i * 9, y + 6, x + 7.5 + i * 9, y + s - 5, x + 12 + i * 9, y + 6);
px(ctx, x + 1, y, s - 2, 2, '#2a2a34');
} else if (dir === 2) { // 朝左(贴右墙)
px(ctx, x + s - 7, y + 1, 7, s - 2, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + s - 6, y + 3 + i * 9, x + 5, y + 7.5 + i * 9, x + s - 6, y + 12 + i * 9);
px(ctx, x + s - 2, y + 1, 2, s - 2, '#2a2a34');
} else { // 朝右(贴左墙)
px(ctx, x, y + 1, 7, s - 2, C.spikeDark);
for (let i = 0; i < 3; i++) tri(ctx, x + 6, y + 3 + i * 9, x + s - 5, y + 7.5 + i * 9, x + 6, y + 12 + i * 9);
px(ctx, x, y + 1, 2, s - 2, '#2a2a34');
}
}
function tri(ctx, x1, y1, x2, y2, x3, y3) {
ctx.beginPath();
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath();
ctx.fillStyle = C.spike; ctx.fill();
ctx.strokeStyle = '#8d8da0'; 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;
const sq = p.squash; // 1 正常
ctx.save();
ctx.translate(x + w / 2, y + h);
ctx.scale(1 / sq, sq);
ctx.translate(-w / 2, -h);
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');
// 嘴(死亡张嘴)
if (p.dead) { px(ctx, face > 0 ? w - 6 : 3, 6, 3, 2, '#5a1a1a'); }
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 };
+134
View File
@@ -0,0 +1,134 @@
// 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-help', 'scr-pause', 'scr-result', 'scr-editor'];
const TABS = [
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
];
export const UI = {
H: {}, // handlers
filter: 'all',
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;
$$('#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.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);
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' : '');
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>' : ''}
`;
card.onclick = () => this.H.play(l);
grid.appendChild(card);
});
},
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+45
View File
@@ -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;
}
+40
View File
@@ -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})`);
+55
View File
@@ -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);
+89
View File
@@ -0,0 +1,89 @@
// 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.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null,
innerWidth: 1280, innerHeight: 800,
};
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);
+54
View File
@@ -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 + ' 帧)'}`);
}
+95
View File
@@ -0,0 +1,95 @@
// 回归测试: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.window = {
addEventListener: addL, removeEventListener: noop,
AudioContext: null, webkitAudioContext: null, innerWidth: 1280, innerHeight: 800,
};
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;
key('keydown', 'KeyR');
step(2);
key('keyup', 'KeyR');
step(60);
const survivedLevel = api.G.level === L0;
console.log(` 按 R 后:关卡对象未被重建=${survivedLevel} 玩家x=${api.G.player.x.toFixed(0)} 死亡=${deaths0}→${api.G.deaths}`);
if (!survivedLevel) err('R 键把整关重开了,存档进度丢失');
if (Math.abs(api.G.player.x - cp.x) > 4) err(`R 键后没有回到存档点(x=${api.G.player.x.toFixed(0)} 期望 ${cp.x.toFixed(0)})`);
if (api.G.deaths !== deaths0 + 1) err('R 键没有计入死亡数');
if (Math.abs(api.G.player.x - L0.spawn.x) < 4) 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);
+110
View File
@@ -0,0 +1,110 @@
// 诊断:打印可达站立点的最大 x(按行),找出断连位置
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 TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
function solidAt(L, br, 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(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
function jump(L, br, fx, fy, vx0, djump, flip) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) { const nx = x + vx; if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0; }
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); const ny = y + dy * m;
if (!solidAt(L, br, 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 (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(L, br, fx, fy, dir) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem); const ny = y + m;
if (!solidAt(L, br, x, ny, PW, PH, true, y + PH)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
const id = process.argv[2] || 'j6';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const br = [];
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;
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]), ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w, y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) { if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; } if (sy + k > L.pxH) break; }
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]); const q = [start]; let n = 0;
const byRow = new Map();
const note = s => {
const r = Math.round(s.fy / TILE);
const cur = byRow.get(r) || { max: -1, min: 1e9 };
byRow.set(r, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
};
note(start);
while (q.length && n < 40000) {
const s = q.shift(); n++;
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) { const ns = { fx: nx, fy: s.fy }; const k = key(ns); if (!seen.has(k)) { seen.add(k); q.push(ns); note(ns); } }
}
for (const d2 of [-1, 1]) { const r = fall(L, br, s.fx, s.fy, d2); if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } } }
for (const vx of [-HSPD, 0, HSPD]) for (const dj of [0, 6, 12, 18, 24, 30]) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0);
if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } }
if (vx !== 0) { const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22); if (r2 && canStand(L, br, r2.fx, r2.fy)) { const k = key(r2); if (!seen.has(k)) { seen.add(k); q.push(r2); note(r2); } } }
}
}
console.log(`${id} ${d.name} ${L.w}x${L.h} 可达站立行数=${byRow.size} 状态=${seen.size}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(r => {
const v = byRow.get(r);
console.log(` row ${String(r).padStart(2)} (y=${r * 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}`);
+31
View File
@@ -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(' | ')}`);
}
+58
View File
@@ -0,0 +1,58 @@
// 存档点专项测试:触碰 -> 激活 -> 死亡 -> 回到存档点
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();
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);
+100
View File
@@ -0,0 +1,100 @@
// 无头冒烟测试:构造全部关卡 + 模拟输入跑物理
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++; };
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();
for (let i = 0; i < 80; i++) { L.update(1, P, null); P.update(1); Input.endFrame(); }
console.log(` 死亡计数=${deaths} 重生后 alive=${P.alive} 位置=(${P.x.toFixed(0)},${P.y.toFixed(0)})`);
if (!P.alive) err('死亡后未复活');
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);
+207
View File
@@ -0,0 +1,207 @@
// 可达性求解器:用真实物理常量做 BFS,验证终点 / 存档点能否到达
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');
// 可用 DJ=0,12,24 收紧二段跳时机选项,检验关卡是不是只有"某一帧"能过
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
// ---- 碰撞(单向平台:只有下落且上一帧还在平台面之上才挡)----
function solidAt(L, br, 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;
}
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;
/** 站立位置 (fx,fy)=脚底中心左端;返回是否合法 */
function canStand(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
/** 模拟一次跳跃:返回 {fx,fy} 落点 或 null;途中记录是否碰到目标 */
function jump(L, br, fx, fy, vx0, djump, flip, targets) {
let x = fx, y = fy - PH;
let vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0;
}
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);
const ny = y + dy * m;
if (!solidAt(L, br, 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(L, br, fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem);
const ny = y + m;
if (!solidAt(L, br, 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;
}
function solve(d) {
const L = new Level(d);
const br = [];
// 水:视为可站立的表面(游泳近似)
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;
// 移动平台:整条路径当作桥(乐观)
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 });
}
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, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
// 起点:从出生点往下找落脚点
let sx = L.spawn.x, sy = L.spawn.y + PH;
let start = null;
for (let k = 0; k < 400; k++) {
if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方 400px 内找不到落脚点', targets };
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]);
const q = [start];
let n = 0, steps = 0;
// 起跳时就检查一次(原地也算到过)
while (q.length && n < 40000) {
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 && overlap(box, tg.rect)) tg.hit = true;
// 走路
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) {
const ns = { fx: nx, fy: s.fy };
const k = key(ns);
if (!seen.has(k)) { seen.add(k); q.push(ns); }
}
}
// 走下悬崖
for (const d2 of [-1, 1]) {
const r = fall(L, br, s.fx, s.fy, d2, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
}
// 跳跃(含二段跳时机 / 空中转向)
for (const vx of [-HSPD, 0, HSPD]) {
for (const dj of DJSET) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
if (vx !== 0) {
const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22, targets);
steps++;
if (r2 && canStand(L, br, r2.fx, r2.fy)) {
const k = key(r2);
if (!seen.has(k)) { seen.add(k); q.push(r2); }
}
}
}
}
}
return { states: seen.size, steps, targets };
}
// ---------------- 运行 ----------------
let fail = 0;
console.log('=== 可达性求解 ===');
for (const d of LEVELS) {
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
let r;
try { r = solve(d); }
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.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} 模拟${r.steps})`);
}
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
process.exit(fail ? 1 : 0);
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# 双击启动本地服务器并打开游戏
cd "$(dirname "$0")"
PORT=8123
python3 -m http.server "$PORT" >/dev/null 2>&1 &
SRV=$!
sleep 1.5
open "http://localhost:$PORT/"
echo "游戏已启动: http://localhost:$PORT/ (关闭本窗口即可停止服务器)"
wait $SRV