fix: 刺放置方向问题
This commit is contained in:
1 parent
6ba47dfd88
commit
bf0f065432
8 files changed
+576
-116
No files matched your search
@@ -55,3 +55,42 @@
|
||||
- 用户要求:把死亡效果从血洼/血点/飞溅碎块改成简单的**红叉**即可。
|
||||
- `js/level.js`:`addDeathMark` 只记 `{x,y}`(不再生成 parts 碎块);`drawDeathMark` 改为画一个带深色描边的红色 X(半边长 9,lineWidth 5 黑描边 + 3 红主体);`update()` 去掉碎块演化循环(红叉为常驻静态标记)。仍只在死亡地点显示、只保留上一次、整关重开清空。
|
||||
- `tools/death-test.mjs` 措辞「血迹」→「红叉」。全套仍全绿。
|
||||
|
||||
## 自制关卡:可变宽高 + 传送门 + 多面地图 + 极简导入导出(用户新需求)
|
||||
- 需求:编辑器里可自定义地图宽高;引入「传送门(W)」,一个地图可含**多面(子关卡)**;导入导出对非专业者友好。
|
||||
- 经 `AskUserQuestion` 确认三处设计:
|
||||
- 传送门配对 = **显式指定目标**(每个门直接选「去哪个面 + 哪个门」,不自动两两配对);
|
||||
- 面尺寸 = **每个面独立宽高**(clamp 12–160 × 8–80);
|
||||
- 导入导出 = **一键文件 + 分享码**(下载 .json / 复制分享码;从文件导入 / 粘贴导入)。
|
||||
- `js/level.js`:`reset()` 里从 `this.data.doors` 读取门目标 `to:{face,door}` 写到 warp 实体的 `e.to`;`e.doorId` 取门 id、`e.pair` 兜底取相邻序号、`e.cool/hidden` 支持。
|
||||
- `js/player.js`:warp 分支识别「跨面目标对象(`to.face` 为 number 且无 x/y)」→ 调 `this.onPortal(e)` 交主程序处理,否则走原 `spawn`/坐标/`pair` 逻辑;save 分支把 `face` 写进 `savePoint`。
|
||||
- `js/main.js`:`G.map[]` + `G.face`;`startLevel` 在 `data.faces` 存在时建多面;`doPortal(e)`→`switchFace` 换激活 Level、`dest.cool=40` 防回弹;`doRespawnFace()` 回到 `savePoint.face`;HUD 多面时显示 `面N/M`。
|
||||
- `js/editor.js` 重写:状态 `faces[]`(每面 `{name,w,h,seq,grid,doors}`);`resize(w,h)` 可变高、裁剪越界门、`addFace/copyFace/delFace`;门放 `W` 自动注册 id,`renderDoors()` 每门两个下拉(目标面+目标门,禁自连);`buildData()` 输出 `{name,type:'custom',startFace:0,faces:[...]}`;`validate()` 校验每面有 1 个 S 且 ≥1 个 X、门不自连;导入导出 `download/copyCode/importFile/importMap`(兼容旧单 grid `doors` 格式自动包成 1 面);3 处 `confirm()` 加 `typeof confirm==='function'` 守卫以兼容 Node。
|
||||
- `index.html` + `css/style.css`:编辑器区改为 `ed-name/ed-w/ed-h/ed-resize` + 面面板(`ed-addface/copyface/delface` + `#ed-faces`) + 门面板(`#ed-doors`) + 一键导入导出(`ed-download/ed-copy/ed-file/ed-import/ed-export` + `#ed-json` textarea);`.editor-main{overflow:auto}`。
|
||||
- 新增 `tools/map-test.mjs`(4 项:跨面门目标挂载 / `onPortal` 触发 / `savePoint.face` 记录 / 旧格式兼容)。`/tmp/ed_harness.mjs` 8 断言(buildData 双面、门目标读写、validate、importMap 保留、增删面计数、resize、旧单 grid 导入、缺 S 拦截)全过。
|
||||
- 验证:`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套全绿;23 关可达性未退化;预览服务 `8123` 返回 200。
|
||||
|
||||
## 编辑器:去掉导入导出,传送门改为「属性面板」方式(用户新需求)
|
||||
- 用户要求:去掉导入/导出,只用「保存」;把多关传送门设置改成「修改属性」方式——在自制关卡**右侧**显示一列「属性列表」,展示**当前选中方块**的可设属性(含跳转到下一关),其他有属性的方块也放进去。
|
||||
- `js/editor.js` 改动:
|
||||
- 删除 `download/copyCode/importFile/importMap/importPaste/exportToTextarea/mapString/renderDoors` 全部导入导出与独立传送门面板逻辑;保留 `save()` 与「我的地图」「面」面板。
|
||||
- 新增 **每格属性** 模型 `f.props[ty][tx]`(null 或对象),与 `grid` 同形;`blank/propsBlank` 同步、`resize/copyFace/hydrate` 都维护 props。
|
||||
- 新增 `SCHEMA`:按方块类型声明可编辑字段(`portal` / `text` / `num` / `select`)。`W`→跳转到下一关(两下拉写 `doors[].to`);`T`→提示文字;`O`→朝向/间隔/速度/延迟;`C`→区域宽高/镜头模式/缩放;`B`→类型/血量。
|
||||
- 选中格 `this.selTile`:画布点击即选中并高亮(黄框);调色板首项 `⌖ 查看属性` 为只查看不修改的画笔。右侧 `#ed-props` 由 `renderProps()` 按 `SCHEMA` 渲染。
|
||||
- `buildData()` 按扫描顺序把 `props` 导出为 `messages/turrets/camzones/bosses` 数组(引擎按扫描下标读取,顺序一致);`doors` 原样导出。
|
||||
- `hydrate(o)` 反向:按扫描顺序把这些数组回填到 `f.props[ty][tx]`。
|
||||
- `index.html`:删除「导入/导出」区块(按钮 + `#ed-json`)与「传送门(显式指定目标)」面板;新增 `#ed-props` 属性列表面板;`editor-tip` 文案更新。
|
||||
- `css/style.css`:新增 `#ed-props/.prop-head/.prop-empty/.prop-row/.prop-lab/.prop-in/.prop-ctl` 样式;删除无用的 `#ed-json/.io-btns/.door-row` 样式。
|
||||
- 验证:Node 桩冒烟(`/tmp/ed_props_harness2.mjs`,14 断言全过:各类型 renderProps 不抛异常、buildData 各数组导出正确、hydrate 往返恢复、validate 门自指/缺 S 拦截);`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套仍全绿。
|
||||
|
||||
## 编辑器:放置不覆盖 + 刺合并为单类型(方向走属性面板)(用户再次要求)
|
||||
- 需求:①放置方块时若当前格已有方块则不覆盖;②把「上/下/左/右」四个刺合并成**一个刺**,上下左右方向改到右侧属性面板调。
|
||||
- 引擎侧不动:`js/level.js` 的 `CHAR2TILE` 仍是 `^=SPIKE_U / v=SPIKE_D / <=SPIKE_L / >=SPIKE_R`,`spikeBox()` 按 T.SPIKE_* 收紧判定。所以只在**编辑器层**做「单 char + props.dir ↔ 四字符」双向翻译,`solve/check` 等可达性完全不受影响。
|
||||
- `js/editor.js`:
|
||||
- `PALETTE` 删掉 `v < >`,只留 `['^','刺',...]`;新增 `SPIKE_CHAR{up:^,down:v,left:<,right:>}` / `SPIKE_DIR`(反向) / `SPIKE_ARROW{up:↑,...}`。
|
||||
- `SCHEMA` 增加 `'^': [{type:'select',key:'dir',def:'up',opts:[上/下/左/右]}]`;`renderProps` 的 select 分支改为 `cur ?? fd.def` 兜底默认值。
|
||||
- `act()` 放置段新增:`if(!erase && ch!=='.' && prev!=='.') { this.renderProps(); return; }` —— 放实方块遇已有方块直接跳过(仍选中该格以便看属性);擦除/橡皮 `ch==='.'` 与 `?` 查看工具不受此限。
|
||||
- `draw()` 刺格不再写 `^`,改写 `SPIKE_ARROW[dir]`(↑↓←→)让方向在画布上可见。
|
||||
- `buildData()` 导出 grid 时把每格 `^` 按 `props.dir` 翻译成 `^ v < >`;`hydrate()` 反向把 `^ v < >` 统一还原成 `^` 并写 `props.dir`。往返一致。
|
||||
- `index.html`:`editor-tip` 改为说明「不会覆盖已有方块」「刺只有一个、方向在右侧属性面板调」。
|
||||
- 验证:Node 桩冒烟(`/tmp/ed_noclip_spike.mjs`,13 断言全过:不覆盖/擦除/橡皮、buildData 四向翻译、renderProps('^') 不抛、hydrate 往返一致);`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套全绿。
|
||||
+22
-4
@@ -149,15 +149,17 @@ canvas#game{
|
||||
.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-main{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;overflow:auto}
|
||||
.editor-bar{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
|
||||
.editor-bar input,.editor-bar select{
|
||||
background:#111119;border:1px solid var(--line);color:var(--fg);
|
||||
font-family:inherit;font-size:.85em;padding:4px 6px;
|
||||
}
|
||||
#ed-name{width:110px}#ed-w{width:52px}
|
||||
#ed-name{width:110px}#ed-w{width:52px}#ed-h{width:52px}
|
||||
.ed-wh{display:inline-flex;align-items:center;gap:3px;font-size:.78em;color:var(--dim)}
|
||||
.ed-wh input{width:48px}
|
||||
canvas#ed-canvas{
|
||||
width:100%;height:auto;background:#0d0d16;border:1px solid var(--line);
|
||||
width:800px;height:auto;background:#0d0d16;border:1px solid var(--line);
|
||||
image-rendering:pixelated;cursor:crosshair;display:block;
|
||||
}
|
||||
.editor-tip{color:var(--dim);font-size:.78em}
|
||||
@@ -167,7 +169,23 @@ canvas#ed-canvas{
|
||||
.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}
|
||||
.face-btns{display:flex;gap:4px;flex-wrap:wrap}
|
||||
#ed-faces{display:flex;flex-direction:column;gap:4px;max-height:160px;overflow-y:auto}
|
||||
.face-row{background:#1b1b26;border:1px solid var(--line);padding:3px 5px;font-size:.78em;cursor:pointer;display:flex;flex-direction:column;gap:2px}
|
||||
.face-row:hover{border-color:var(--gold)}
|
||||
.face-row.on{border-color:var(--gold);background:#2b2b3c}
|
||||
.face-row .face-name{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:2px 4px;width:100%}
|
||||
.face-row .face-wh{color:var(--dim);font-size:.9em}
|
||||
/* 属性面板(选中方块) */
|
||||
#ed-props{display:flex;flex-direction:column;gap:5px;max-height:36vh;overflow-y:auto;border:1px solid var(--line);background:#111119;padding:6px}
|
||||
.prop-empty{color:var(--dim);font-size:.76em;line-height:1.5}
|
||||
.prop-head{color:var(--gold);font-weight:bold;font-size:.92em;letter-spacing:1px;margin-bottom:2px}
|
||||
.prop-row{display:flex;flex-direction:column;gap:3px;background:#1b1b26;border:1px solid var(--line);padding:4px 5px;font-size:.78em}
|
||||
.prop-lab{color:var(--fg)}
|
||||
.prop-note{color:var(--dim);font-size:.92em}
|
||||
.prop-ctl{display:flex;gap:4px}
|
||||
.prop-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:3px 4px;width:100%}
|
||||
.prop-ctl .prop-in{width:auto;flex:1}
|
||||
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:#111}
|
||||
|
||||
+14
-14
@@ -142,14 +142,9 @@
|
||||
<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="宽度(格)">
|
||||
<input id="ed-name" placeholder="地图名" value="MY MAP">
|
||||
<label class="ed-wh">宽<input id="ed-w" type="number" value="25" min="12" max="160"></label>
|
||||
<label class="ed-wh">高<input id="ed-h" type="number" value="19" min="8" max="80"></label>
|
||||
<button class="btn tiny" data-act="ed-resize">调整</button>
|
||||
<button class="btn tiny" data-act="ed-fill">铺底</button>
|
||||
<button class="btn tiny" data-act="ed-clear">清空</button>
|
||||
@@ -157,15 +152,20 @@
|
||||
<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 class="editor-tip">左键放置 / 右键擦除,滚轮水平平移。<b>不会覆盖已有方块</b>——放在空格才生效,要换掉先擦除。选「⌖ 查看属性」再点方块可只查看不修改。<b>刺</b>只有一个,放好后在右侧「属性」面板调<b>方向(上/下/左/右)</b>;传送门 <b>W</b>、提示牌 <b>T</b>、炮台 <b>O</b>、摄像头 <b>C</b>、BOSS <b>B</b> 的属性也都在这里设置。每面至少 1 个 <b>S</b>(出生点) 与 1 个 <b>X</b>(终点)。</div>
|
||||
</div>
|
||||
<div class="editor-side">
|
||||
<h3>我的关卡</h3>
|
||||
<h3>属性(选中方块)</h3>
|
||||
<div id="ed-props"><div class="prop-empty">点击画布上的方块,查看并设置它的属性</div></div>
|
||||
<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>
|
||||
<h3>面(多面地图)</h3>
|
||||
<div class="face-btns">
|
||||
<button class="btn tiny" data-act="ed-addface">+ 新增面</button>
|
||||
<button class="btn tiny" data-act="ed-copyface">复制面</button>
|
||||
<button class="btn tiny" data-act="ed-delface">删除面</button>
|
||||
</div>
|
||||
<div id="ed-faces"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+332
-86
@@ -1,26 +1,61 @@
|
||||
// 简易关卡编辑器
|
||||
// 自制关卡编辑器(多面地图 + 可变宽高 + 属性面板)
|
||||
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 clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
|
||||
const PALETTE = [
|
||||
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||||
['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||||
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
|
||||
['^', '上刺', '#c8c8d8'], ['v', '下刺', '#a0a0b4'], ['<', '左刺', '#9090a8'], ['>', '右刺', '#9090a8'],
|
||||
['^', '刺', '#c8c8d8'],
|
||||
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
|
||||
['P', '手枪', '#b8b8c8'], ['F', '苹果', '#ff8095'], ['J', '跳刷新', '#3ddc84'], ['W', '传送门', '#a86bff'],
|
||||
['T', '提示牌', '#c9a86b'], ['O', '炮台', '#6a6a80'], ['D', '掉落刺', '#8a5a6a'], ['C', '摄像头', '#7ae0ff'],
|
||||
['B', 'BOSS', '#ff5c7a'], ['!', '触发器', '#ff9a3a'], ['.', '橡皮', '#20202c'],
|
||||
];
|
||||
|
||||
// 每个方块类型可编辑的属性(属性面板按此渲染)
|
||||
// type: portal(传送门目标) | text | num | select
|
||||
const SCHEMA = {
|
||||
'^': [{ type: 'select', key: 'dir', label: '方向', def: 'up', opts: [['up', '上'], ['down', '下'], ['left', '左'], ['right', '右']] }],
|
||||
W: [{ type: 'portal' }],
|
||||
T: [{ type: 'text', key: 'msg', label: '提示文字', placeholder: '这块牌子显示的话' }],
|
||||
O: [
|
||||
{ type: 'select', key: 'dir', label: '朝向', opts: [['left', '左'], ['right', '右'], ['up', '上'], ['down', '下']] },
|
||||
{ type: 'num', key: 'period', label: '发射间隔(帧)', min: 10, max: 600, def: 90 },
|
||||
{ type: 'num', key: 'speed', label: '子弹速度', min: 0.5, max: 10, step: 0.1, def: 2.4 },
|
||||
{ type: 'num', key: 'offset', label: '初始延迟(帧)', min: 0, max: 600, def: 0 },
|
||||
],
|
||||
C: [
|
||||
{ type: 'num', key: 'w', label: '区域宽(格)', min: 1, max: 40, def: 3 },
|
||||
{ type: 'num', key: 'h', label: '区域高(格)', min: 1, max: 40, def: 3 },
|
||||
{ type: 'select', key: 'mode', label: '镜头模式', opts: [['lock', '锁定'], ['zoom', '缩放'], ['follow-off', '跟随关闭']] },
|
||||
{ type: 'num', key: 'zoom', label: '缩放倍数', min: 0.3, max: 3, step: 0.1, def: 1 },
|
||||
],
|
||||
B: [
|
||||
{ type: 'select', key: 'kind', label: 'BOSS 类型', opts: [['guy', '小兵'], ['mecha', '机甲']] },
|
||||
{ type: 'num', key: 'hp', label: '血量', min: 1, max: 999, def: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
|
||||
|
||||
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
|
||||
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
|
||||
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
|
||||
const SPIKE_ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||||
|
||||
export const Editor = {
|
||||
api: {},
|
||||
map: null, w: 25, sel: '#',
|
||||
scroll: 0, drag: 0, cur: null,
|
||||
canvas: null, ctx: null,
|
||||
// 多面地图状态
|
||||
faces: null, face: 0,
|
||||
// 当前选中的方块坐标(属性面板依据它渲染)
|
||||
selTile: null,
|
||||
|
||||
init(api) {
|
||||
this.api = api;
|
||||
@@ -28,7 +63,8 @@ export const Editor = {
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.buildPalette();
|
||||
this.bind();
|
||||
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value || 25));
|
||||
$('#ed-w').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
$('#ed-h').addEventListener('change', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
this.renderMyLevels();
|
||||
},
|
||||
|
||||
@@ -38,7 +74,7 @@ export const Editor = {
|
||||
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.innerHTML = `<span class="sw" style="background:${col}"></span><span class="ch">${ch === '?' ? '⌖' : ch}</span><span>${label}</span>`;
|
||||
d.onclick = () => {
|
||||
this.sel = ch;
|
||||
Array.from(p.children).forEach(c => c.classList.remove('on'));
|
||||
@@ -57,10 +93,25 @@ export const Editor = {
|
||||
return { tx: Math.floor((sx + this.scroll) / TILE), ty: Math.floor(sy / TILE) };
|
||||
};
|
||||
const act = (e, erase) => {
|
||||
const f = this.curFace();
|
||||
const { tx, ty } = pos(e);
|
||||
if (tx < 0 || ty < 0 || tx >= this.w || ty >= H_ROWS) return;
|
||||
this.map[ty][tx] = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
|
||||
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
|
||||
// 选中该格(属性面板依据它渲染)
|
||||
this.selTile = { tx, ty };
|
||||
// 「查看属性」工具:只选中,不修改地图
|
||||
if (this.sel === '?') { this.renderProps(); return; }
|
||||
const prev = f.grid[ty][tx];
|
||||
const ch = erase ? '.' : (this.sel === '.' ? '.' : this.sel);
|
||||
// 放置不覆盖:放实方块时若该格已有方块则跳过(仍选中以便查看属性)
|
||||
if (!erase && ch !== '.' && prev !== '.') { this.renderProps(); return; }
|
||||
// 门的增删:放上传送门自动登记一扇门;擦掉或覆盖传送门则移除对应门
|
||||
if (prev === 'W' && ch !== 'W') this.removeDoor(f, tx, ty);
|
||||
if (ch === 'W' && prev !== 'W') { f.seq = (f.seq || 0) + 1; f.doors.push({ id: f.seq, tx, ty, to: null }); }
|
||||
// 改变方块种类时清空该格属性(属性按类型存储)
|
||||
if (prev !== ch) f.props[ty][tx] = null;
|
||||
f.grid[ty][tx] = ch;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
};
|
||||
c.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
@@ -72,55 +123,118 @@ export const Editor = {
|
||||
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.scroll = Math.max(0, Math.min((this.curFace().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();
|
||||
const on = (actName, fn) => { const el = document.querySelector(`[data-act="${actName}"]`); if (el) el.onclick = fn; };
|
||||
on('ed-resize', () => this.resize(+$('#ed-w').value, +$('#ed-h').value));
|
||||
on('ed-fill', () => { const f = this.curFace(); for (let x = 0; x < f.w; x++) f.grid[f.h - 1][x] = '#'; this.draw(); });
|
||||
on('ed-clear', () => { if (typeof confirm === 'function' && !confirm('清空当前面?')) return; const f = this.curFace(); f.grid = blank(f.w, f.h); f.doors = []; f.props = propsBlank(f.w, f.h); this.selTile = null; this.draw(); this.renderProps(); });
|
||||
on('ed-test', () => this.test());
|
||||
on('ed-save', () => this.save());
|
||||
on('ed-addface', () => this.addFace());
|
||||
on('ed-copyface', () => this.copyFace());
|
||||
on('ed-delface', () => this.delFace());
|
||||
},
|
||||
|
||||
// ---------------- 多面地图状态 ----------------
|
||||
curFace() { return this.faces[this.face]; },
|
||||
|
||||
open() {
|
||||
if (!this.map) { this.w = +$('#ed-w').value || 25; this.map = blank(this.w); }
|
||||
if (!this.faces) {
|
||||
const w = +$('#ed-w').value || 25, h = +$('#ed-h').value || 19;
|
||||
this.faces = [{ name: '面1', w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) }];
|
||||
this.face = 0;
|
||||
}
|
||||
this.selTile = null;
|
||||
$('#ed-w').value = this.curFace().w;
|
||||
$('#ed-h').value = this.curFace().h;
|
||||
this.draw();
|
||||
this.renderFaces();
|
||||
this.renderProps();
|
||||
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;
|
||||
resize(w, h) {
|
||||
w = clamp(w || 25, 12, 160);
|
||||
h = clamp(h || 19, 8, 80);
|
||||
const f = this.curFace();
|
||||
const og = f.grid, op = f.props;
|
||||
f.grid = blank(w, h);
|
||||
f.props = propsBlank(w, h);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
if (og[y] && og[y][x] !== undefined) f.grid[y][x] = og[y][x];
|
||||
if (op[y] && op[y][x] !== undefined) f.props[y][x] = op[y][x];
|
||||
}
|
||||
f.doors = f.doors.filter(d => d.tx < w && d.ty < h);
|
||||
f.w = w; f.h = h;
|
||||
$('#ed-w').value = w; $('#ed-h').value = h;
|
||||
this.canvas.height = h * TILE;
|
||||
this.selTile = null;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
},
|
||||
|
||||
addFace() {
|
||||
const w = this.curFace().w, h = this.curFace().h;
|
||||
this.faces.push({ name: '面' + (this.faces.length + 1), w, h, seq: 0, grid: blank(w, h), doors: [], props: propsBlank(w, h) });
|
||||
this.face = this.faces.length - 1;
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
copyFace() {
|
||||
const f = this.curFace();
|
||||
const copy = {
|
||||
name: f.name + ' 副本', w: f.w, h: f.h, seq: f.seq,
|
||||
grid: f.grid.map(r => r.slice()),
|
||||
doors: f.doors.map(d => ({ ...d })),
|
||||
props: f.props.map(r => r.map(c => (c ? { ...c } : null))),
|
||||
};
|
||||
this.faces.push(copy);
|
||||
this.face = this.faces.length - 1;
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
delFace() {
|
||||
if (this.faces.length <= 1) { alert('至少保留一个面'); return; }
|
||||
if (typeof confirm === 'function' && !confirm('删除「' + this.curFace().name + '」?该面的门与连接都会消失')) return;
|
||||
this.faces.splice(this.face, 1);
|
||||
this.face = Math.max(0, this.face - 1);
|
||||
this.afterFaceSwitch();
|
||||
},
|
||||
afterFaceSwitch() {
|
||||
$('#ed-w').value = this.curFace().w;
|
||||
$('#ed-h').value = this.curFace().h;
|
||||
this.scroll = 0;
|
||||
this.selTile = null;
|
||||
this.draw();
|
||||
this.renderFaces();
|
||||
this.renderProps();
|
||||
},
|
||||
|
||||
removeDoor(f, tx, ty) {
|
||||
const i = f.doors.findIndex(d => d.tx === tx && d.ty === ty);
|
||||
if (i >= 0) f.doors.splice(i, 1);
|
||||
},
|
||||
|
||||
// ---------------- 绘制 ----------------
|
||||
draw() {
|
||||
const ctx = this.ctx, c = this.canvas;
|
||||
c.width = 800; c.height = H_ROWS * TILE;
|
||||
const f = this.curFace();
|
||||
c.width = 800; c.height = f.h * TILE;
|
||||
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.save();
|
||||
ctx.translate(-this.scroll, 0);
|
||||
for (let y = 0; y < H_ROWS; y++) {
|
||||
for (let x = 0; x < this.w; x++) {
|
||||
const ch = this.map[y][x];
|
||||
for (let y = 0; y < f.h; y++) {
|
||||
for (let x = 0; x < f.w; x++) {
|
||||
const ch = f.grid[y][x];
|
||||
const px = x * TILE, py = y * TILE;
|
||||
if (ch !== '.') {
|
||||
let col = '#555';
|
||||
const item = PALETTE.find(p => p[0] === ch);
|
||||
ctx.fillStyle = item ? item[2] : '#555';
|
||||
ctx.globalAlpha = ch === '.' ? 0.2 : 0.85;
|
||||
if (item) col = item[2];
|
||||
if (ch === 'W') { const d = f.doors.find(o => o.tx === x && o.ty === y); col = (d && d.to) ? '#3ddc84' : '#8a8a9a'; }
|
||||
ctx.fillStyle = col;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -130,32 +244,158 @@ export const Editor = {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
|
||||
if (ch === 'W') {
|
||||
const d = f.doors.find(o => o.tx === x && o.ty === y);
|
||||
ctx.fillText('W' + (d ? d.id : ''), px + TILE / 2, py + TILE / 2);
|
||||
} else if (ch === '^') {
|
||||
// 刺:用箭头直观显示方向
|
||||
const dir = ((f.props[y][x] || {}).dir) || 'up';
|
||||
ctx.fillText(SPIKE_ARROW[dir] || '↑', px + TILE / 2, py + TILE / 2);
|
||||
} else ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 选中方块高亮
|
||||
if (this.selTile && this.selTile.tx >= 0 && this.selTile.ty >= 0 && this.selTile.tx < f.w && this.selTile.ty < f.h) {
|
||||
const sx = this.selTile.tx * TILE, sy = this.selTile.ty * TILE;
|
||||
ctx.strokeStyle = '#ffd23a'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
// ---------- 数据 ----------
|
||||
// ---------------- 面列表面板 ----------------
|
||||
renderFaces() {
|
||||
const box = $('#ed-faces');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
this.faces.forEach((f, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'face-row' + (i === this.face ? ' on' : '');
|
||||
d.innerHTML = `<input class="face-name" value="${escapeHtml(f.name)}" title="面名称"><span class="face-wh">${f.w}×${f.h} · ${f.doors.length}门</span>`;
|
||||
d.querySelector('.face-name').addEventListener('change', e => { f.name = e.target.value; });
|
||||
d.querySelector('.face-name').addEventListener('click', e => e.stopPropagation());
|
||||
d.onclick = () => { this.face = i; this.afterFaceSwitch(); };
|
||||
box.appendChild(d);
|
||||
});
|
||||
},
|
||||
|
||||
// ---------------- 属性面板(选中方块) ----------------
|
||||
renderProps() {
|
||||
const box = $('#ed-props');
|
||||
if (!box) return;
|
||||
const f = this.curFace();
|
||||
if (!this.selTile) { box.innerHTML = '<div class="prop-empty">点击画布上的方块,查看并设置它的属性</div>'; return; }
|
||||
const { tx, ty } = this.selTile;
|
||||
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) { box.innerHTML = '<div class="prop-empty">(越界)</div>'; return; }
|
||||
const ch = f.grid[ty][tx];
|
||||
const label = charLabel(ch);
|
||||
const fields = SCHEMA[ch];
|
||||
if (!fields) {
|
||||
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div><div class="prop-empty">该方块没有可设置的属性</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = `<div class="prop-head">「${ch}」 ${label}</div>`;
|
||||
for (const fd of fields) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'prop-row';
|
||||
if (fd.type === 'portal') {
|
||||
const door = f.doors.find(d => d.tx === tx && d.ty === ty);
|
||||
if (!door) { row.innerHTML = '<span class="prop-lab">跳转到下一关</span><span class="prop-note">(此格未登记为门,请重新放置 W)</span>'; box.appendChild(row); continue; }
|
||||
const cur = door.to || null;
|
||||
const tf = cur ? cur.face : this.face;
|
||||
const td = cur ? cur.door : -1;
|
||||
const faceOpts = this.faces.map((gf, gi) => `<option value="${gi}" ${gi === tf ? 'selected' : ''}>面${gi + 1}${gf.name !== ('面' + (gi + 1)) ? '·' + escapeHtml(gf.name) : ''}</option>`).join('');
|
||||
const tface = this.faces[tf];
|
||||
const doorOpts = (tface.doors.length
|
||||
? tface.doors.filter(od => !(od.id === door.id && tf === this.face)).map(od => `<option value="${od.id}" ${td === od.id ? 'selected' : ''}>门${od.id}</option>`).join('')
|
||||
: '') || '<option value="-1">(该面暂无其他门)</option>';
|
||||
row.innerHTML = `<span class="prop-lab">跳转到下一关</span><div class="prop-ctl"><select class="p-face">${faceOpts}</select><select class="p-door">${doorOpts}</select></div>`;
|
||||
const fsel = row.querySelector('.p-face');
|
||||
const dsel = row.querySelector('.p-door');
|
||||
const apply = () => {
|
||||
const ntf = +fsel.value, ntd = +dsel.value;
|
||||
door.to = (ntd >= 0) ? { face: ntf, door: ntd } : null;
|
||||
this.draw();
|
||||
this.renderProps();
|
||||
};
|
||||
fsel.onchange = apply;
|
||||
dsel.onchange = apply;
|
||||
} else if (fd.type === 'text') {
|
||||
const val = (f.props[ty][tx] || {})[fd.key] || '';
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="text" placeholder="${fd.placeholder || ''}" value="${escapeHtml(val)}">`;
|
||||
const inp = row.querySelector('.prop-in');
|
||||
inp.oninput = () => { ensureProp(f, tx, ty)[fd.key] = inp.value; };
|
||||
} else if (fd.type === 'num') {
|
||||
const raw = (f.props[ty][tx] || {})[fd.key];
|
||||
const val = raw !== undefined && raw !== null && raw !== '' ? raw : (fd.def !== undefined ? fd.def : '');
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><input class="prop-in" type="number" min="${fd.min !== undefined ? fd.min : ''}" max="${fd.max !== undefined ? fd.max : ''}" step="${fd.step !== undefined ? fd.step : 1}" value="${val}">`;
|
||||
const inp = row.querySelector('.prop-in');
|
||||
inp.oninput = () => {
|
||||
const v = inp.value === '' ? (fd.def !== undefined ? fd.def : null) : (fd.step && String(fd.step).includes('.') ? +inp.value : +inp.value);
|
||||
ensureProp(f, tx, ty)[fd.key] = v;
|
||||
};
|
||||
} else if (fd.type === 'select') {
|
||||
const cur = (f.props[ty][tx] || {})[fd.key];
|
||||
const val = cur !== undefined && cur !== '' ? cur : (fd.def !== undefined ? fd.def : '');
|
||||
const opts = fd.opts.map(([v, t]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${t}</option>`).join('');
|
||||
row.innerHTML = `<span class="prop-lab">${fd.label}</span><select class="prop-in">${opts}</select>`;
|
||||
const sel = row.querySelector('.prop-in');
|
||||
sel.onchange = () => { ensureProp(f, tx, ty)[fd.key] = sel.value; };
|
||||
}
|
||||
box.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- 数据 ----------------
|
||||
buildData() {
|
||||
const faces = this.faces.map(f => {
|
||||
// 编辑器刺统一为 '^'+props.dir,导出时按方向翻译成引擎字符 ^ v < >
|
||||
const grid = f.grid.map((r, y) => r.map((ch, x) => {
|
||||
if (ch === '^') return SPIKE_CHAR[((f.props[y][x] || {}).dir) || 'up'];
|
||||
return ch;
|
||||
}).join(''));
|
||||
// 按扫描顺序收集各类型属性,整型数组(引擎按扫描下标读取)
|
||||
const messages = [], turrets = [], camzones = [], bosses = [];
|
||||
for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) {
|
||||
const ch = f.grid[y][x];
|
||||
const p = f.props[y][x];
|
||||
if (ch === 'T' && p && p.msg !== undefined && p.msg !== '') messages.push(p.msg);
|
||||
if (ch === 'O' && p) turrets.push({ dir: p.dir || 'left', period: +(p.period || 90), speed: +(p.speed || 2.4), offset: +(p.offset || 0), kind: p.kind || 'ball' });
|
||||
if (ch === 'C' && p) camzones.push({ w: +(p.w || 3), h: +(p.h || 3), mode: p.mode || 'lock', zoom: +(p.zoom || 1) });
|
||||
if (ch === 'B' && p) bosses.push({ kind: p.kind || 'guy', hp: +(p.hp || 40) });
|
||||
}
|
||||
return {
|
||||
name: ($('#ed-name').value || 'MY STAGE').toUpperCase(),
|
||||
type: $('#ed-type').value,
|
||||
width: this.w,
|
||||
grid: this.map.map(r => r.join('')),
|
||||
bg: 'custom',
|
||||
hint: '自制关卡',
|
||||
messages: ['自制关卡'],
|
||||
name: f.name,
|
||||
width: f.w,
|
||||
height: f.h,
|
||||
grid,
|
||||
doors: f.doors.map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
|
||||
messages, turrets, camzones, bosses,
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: ($('#ed-name').value || 'MY MAP').toUpperCase(),
|
||||
type: 'custom',
|
||||
startFace: 0,
|
||||
faces,
|
||||
};
|
||||
},
|
||||
|
||||
validate() {
|
||||
const g = this.map.map(r => r.join(''));
|
||||
for (let i = 0; i < this.faces.length; i++) {
|
||||
const g = this.faces[i].grid.map(r => r.join(''));
|
||||
const s = g.join('').split('S').length - 1;
|
||||
const x = g.join('').split('X').length - 1;
|
||||
if (s !== 1) return '必须且只能有一个 S(出生点),当前 ' + s + ' 个';
|
||||
if (x < 1) return '至少要有一个 X(终点)';
|
||||
if (s !== 1) return `面${i + 1}(${this.faces[i].name})必须且只能有 1 个 S,当前 ${s} 个`;
|
||||
if (x < 1) return `面${i + 1}(${this.faces[i].name})至少要 1 个 X(终点)`;
|
||||
}
|
||||
// 门不能指向自己所在格子
|
||||
for (let i = 0; i < this.faces.length; i++) {
|
||||
for (const d of this.faces[i].doors) {
|
||||
if (d.to && d.to.face === i && d.to.door === d.id) return `面${i + 1} 的门${d.id} 不能指向自己`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -175,46 +415,28 @@ export const Editor = {
|
||||
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');
|
||||
if (!box) return;
|
||||
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');
|
||||
const fn = (l.faces || []).length;
|
||||
d.className = 'my-lv' + (l.id === this.cur ? ' on' : '');
|
||||
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}</span><span class="x">✕</span>`;
|
||||
d.innerHTML = `<span>${i + 1}. ${escapeHtml(l.name)}${fn ? ` <b style="color:#a86bff">[${fn}面]</b>` : ''}</span><span class="x">✕</span>`;
|
||||
d.querySelector('span').onclick = () => {
|
||||
this.cur = l.id;
|
||||
this.load(l);
|
||||
this.hydrate(l);
|
||||
$('#ed-name').value = l.name || '';
|
||||
this.afterFaceSwitch();
|
||||
this.renderMyLevels();
|
||||
};
|
||||
d.querySelector('.x').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('删除 ' + l.name + '?')) return;
|
||||
if (typeof confirm === 'function' && !confirm('删除 ' + l.name + '?')) return;
|
||||
Save.removeCustom(l.id);
|
||||
this.renderMyLevels();
|
||||
this.api.refresh?.();
|
||||
@@ -223,22 +445,46 @@ export const Editor = {
|
||||
});
|
||||
},
|
||||
|
||||
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] || '.';
|
||||
// 从存档/数据对象恢复多面 + 每格属性(按扫描顺序回填 messages/turrets/camzones/bosses)
|
||||
hydrate(o) {
|
||||
this.faces = o.faces.map(f => {
|
||||
const w = f.width || (f.grid[0] ? f.grid[0].length : 25);
|
||||
const h = f.height || f.grid.length;
|
||||
const grid = f.grid.map(r => r.split(''));
|
||||
const props = propsBlank(w, h);
|
||||
const face = {
|
||||
name: f.name || '面',
|
||||
w, h,
|
||||
seq: (f.doors || []).reduce((m, d) => Math.max(m, d.id), 0),
|
||||
grid,
|
||||
doors: (f.doors || []).map(d => ({ id: d.id, tx: d.tx, ty: d.ty, to: d.to || null })),
|
||||
props,
|
||||
};
|
||||
let mi = 0, ti = 0, ci = 0, bi = 0;
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const ch = grid[y][x];
|
||||
if (ch === 'T') { const m = (f.messages || [])[mi++]; if (m !== undefined) props[y][x] = { msg: m }; }
|
||||
else if (ch === 'O') { const t = (f.turrets || [])[ti++]; if (t) props[y][x] = { ...t }; }
|
||||
else if (ch === 'C') { const c = (f.camzones || [])[ci++]; if (c) props[y][x] = { ...c }; }
|
||||
else if (ch === 'B') { const b = (f.bosses || [])[bi++]; if (b) props[y][x] = { ...b }; }
|
||||
// 引擎的四个刺字符 ^ v < > 统一还原为编辑器 '^' + props.dir
|
||||
else if (SPIKE_DIR[ch] !== undefined) { grid[y][x] = '^'; props[y][x] = { dir: SPIKE_DIR[ch] }; }
|
||||
}
|
||||
this.draw();
|
||||
return face;
|
||||
});
|
||||
this.face = 0;
|
||||
},
|
||||
};
|
||||
|
||||
function blank(w) {
|
||||
return Array.from({ length: H_ROWS }, () => Array.from({ length: w }, () => '.'));
|
||||
function blank(w, h) {
|
||||
return Array.from({ length: h }, () => Array.from({ length: w }, () => '.'));
|
||||
}
|
||||
function propsBlank(w, h) {
|
||||
return Array.from({ length: h }, () => Array.from({ length: w }, () => null));
|
||||
}
|
||||
function ensureProp(f, tx, ty) {
|
||||
if (!f.props[ty][tx]) f.props[ty][tx] = {};
|
||||
return f.props[ty][tx];
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
+5
-1
@@ -127,8 +127,12 @@ export class Level {
|
||||
}
|
||||
if (s.type === 'warp') {
|
||||
const cfg = this.warpSrc[e.idx] || {};
|
||||
// 自制多面地图:门的目标直接挂在 door.to 上
|
||||
// door.to 可以是跨面 {face, door}(自制门),或原有 'spawn' / {x,y}(内置同关传送)
|
||||
const door = (this.data.doors || []).find(d => d.tx === s.tx && d.ty === s.ty);
|
||||
e.doorId = door ? door.id : e.idx;
|
||||
e.pair = cfg.pair !== undefined ? cfg.pair : (e.idx % 2 === 0 ? e.idx + 1 : e.idx - 1);
|
||||
e.to = cfg.to || null; // {x,y} 绝对像素 / 'spawn'
|
||||
e.to = (door && door.to) ? door.to : (cfg.to || null);
|
||||
e.cool = 0;
|
||||
e.hidden = !!cfg.hidden;
|
||||
}
|
||||
|
||||
+83
-8
@@ -32,21 +32,44 @@ const G = {
|
||||
state: 'menu', // menu | playing | paused | result | editor
|
||||
level: null, player: null, cam: null,
|
||||
data: null, idx: -1,
|
||||
map: null, face: 0, // 多面地图:map=各面 Level 数组,face=当前面下标
|
||||
deaths: 0, ms: 0, hudTick: 0,
|
||||
time: 0,
|
||||
lastLevelId: null,
|
||||
};
|
||||
|
||||
// ---------------- 关卡流程 ----------------
|
||||
// 把一张「面」的数据包装成 Level 可用的 data(门目标来自面自身的 doors)
|
||||
function faceData(map, face, i) {
|
||||
return {
|
||||
id: (map.id || 'map') + '-' + i,
|
||||
name: face.name || ('面' + (i + 1)),
|
||||
type: 'custom',
|
||||
width: face.width || (face.grid[0] ? face.grid[0].length : 25),
|
||||
grid: face.grid,
|
||||
doors: face.doors || [],
|
||||
bg: 'custom', hint: '', messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function startLevel(data, idx = -1) {
|
||||
unlockAudio();
|
||||
G.data = data; G.idx = idx; G.lastLevelId = data.id;
|
||||
G.level = new Level(data);
|
||||
// 自制多面地图:data.faces 存在则构建每面一个 Level;否则按传统单关处理
|
||||
if (data.faces && data.faces.length) {
|
||||
G.map = data.faces.map((f, i) => { const L = new Level(faceData(data, f, i)); L.faceIndex = i; return L; });
|
||||
G.face = Math.max(0, Math.min(data.startFace || 0, G.map.length - 1));
|
||||
} else {
|
||||
G.map = [new Level(data)]; G.face = 0;
|
||||
}
|
||||
G.level = G.map[G.face];
|
||||
G.player = new Player(G.level);
|
||||
G.cam = new Camera(W, H);
|
||||
G.cam.reset(G.level, G.player);
|
||||
G.player.onDeath = () => { G.deaths++; };
|
||||
G.player.onClear = () => finish();
|
||||
G.player.onPortal = (e) => doPortal(e);
|
||||
G.player.onRespawnFace = () => doRespawnFace();
|
||||
G.level.bosses.forEach(b => { b.onDefeat = () => finish(); });
|
||||
G.deaths = 0; G.ms = 0; G.hudTick = 0;
|
||||
G.state = 'playing';
|
||||
@@ -56,6 +79,53 @@ function startLevel(data, idx = -1) {
|
||||
if (data.hint) UI.toast(data.hint);
|
||||
}
|
||||
|
||||
// 把玩家放到某面某门上(不切面的同面传送也用)
|
||||
function placeOnDoor(dest) {
|
||||
const P = G.player;
|
||||
P.x = dest.x + dest.w / 2 - P.w / 2;
|
||||
P.y = dest.y + dest.h - P.h;
|
||||
P.vx = 0; P.vy = 0; P.jumps = 0;
|
||||
}
|
||||
|
||||
// 踩到门:e.to = {face, door}
|
||||
function doPortal(e) {
|
||||
const to = e.to;
|
||||
const tp = Math.max(0, Math.min(G.map.length - 1, to.face | 0));
|
||||
if (tp === G.face) {
|
||||
const dest = G.map[tp].entities.find(o => o.type === 'warp' && o.doorId === to.door);
|
||||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||||
sfx.warp();
|
||||
} else {
|
||||
switchFace(tp, to.door);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到第 f 面,并落到该面的目标门上
|
||||
function switchFace(f, doorId) {
|
||||
G.face = f;
|
||||
G.level = G.map[f];
|
||||
G.level.reset(true); // 进入该面时重置其运行时
|
||||
G.player.level = G.level;
|
||||
const dest = G.level.entities.find(o => o.type === 'warp' && o.doorId === doorId);
|
||||
if (dest) { placeOnDoor(dest); dest.cool = 40; }
|
||||
else { const sp = G.level.spawn; G.player.x = sp.x; G.player.y = sp.y; G.player.vx = 0; G.player.vy = 0; }
|
||||
G.cam.reset(G.level, G.player);
|
||||
sfx.warp();
|
||||
G.level.particles.spawn(G.player.cx, G.player.cy, 12, { colors: ['#a86bff', '#fff'], speed: 2.5, life: 24 });
|
||||
}
|
||||
|
||||
// 复活回到存档点所在面(跨面)
|
||||
function doRespawnFace() {
|
||||
const sp = G.player.savePoint;
|
||||
if (sp && typeof sp.face === 'number' && sp.face !== G.face) {
|
||||
G.face = sp.face;
|
||||
G.level = G.map[sp.face];
|
||||
G.level.reset(true);
|
||||
G.player.level = G.level;
|
||||
G.cam.reset(G.level, G.player);
|
||||
}
|
||||
}
|
||||
|
||||
/** 整关从头再来:保留死亡数,丢弃所有存档点进度 */
|
||||
function restart() {
|
||||
if (!G.data) return;
|
||||
@@ -103,26 +173,31 @@ function resume() { if (G.state === 'paused') { G.state = 'playing'; UI.showGame
|
||||
|
||||
// ---------------- 更新 ----------------
|
||||
function update() {
|
||||
const L = G.level, P = G.player;
|
||||
const P = G.player;
|
||||
// R = 自杀回存档点;Backspace = 整关重开
|
||||
if (Input.hit('suicide')) { suicide(); return; }
|
||||
if (Input.hit('restart')) { restart(); return; }
|
||||
if (Input.hit('pause')) { pause(); return; }
|
||||
|
||||
const wasDead = P.dead;
|
||||
L.update(1, P, G.cam);
|
||||
for (const b of L.bosses) b.update(1, P);
|
||||
G.level.update(1, P, G.cam);
|
||||
for (const b of G.level.bosses) b.update(1, P);
|
||||
P.update(1);
|
||||
// 刚从死亡中复活:镜头立刻对准重生点,别慢慢飘过去
|
||||
if (P.justRespawned || (wasDead && !P.dead)) G.cam.snapTo(P, L);
|
||||
// 刚从死亡中复活:若存档点在别的面,先切回该面,再镜头瞬移
|
||||
if (P.justRespawned) {
|
||||
if (P.savePoint && typeof P.savePoint.face === 'number' && P.savePoint.face !== G.face) doRespawnFace();
|
||||
G.cam.snapTo(P, G.level);
|
||||
P.justRespawned = false;
|
||||
G.cam.update(L, P, 1);
|
||||
}
|
||||
G.cam.update(G.level, P, 1);
|
||||
G.ms += STEP;
|
||||
G.time++;
|
||||
|
||||
if (++G.hudTick % 5 === 0) {
|
||||
const L = G.level;
|
||||
UI.hud({
|
||||
name: G.data.name, deaths: G.deaths, ms: G.ms,
|
||||
name: G.data.name + (G.map.length > 1 ? ` · 面${G.face + 1}/${G.map.length}` : ''),
|
||||
deaths: G.deaths, ms: G.ms,
|
||||
apples: P.apples, totalApples: L.totalApples, hasGun: P.hasGun,
|
||||
save: P.saveIdx || 0, totalSaves: L.totalSaves || 0,
|
||||
});
|
||||
|
||||
+10
-3
@@ -297,7 +297,7 @@ export class Player {
|
||||
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.savePoint = { x: e.x + e.w / 2 - this.w / 2, y: e.y + e.h - this.h, face: (this.level.faceIndex ?? 0) };
|
||||
this.saveIdx = e.no || 1;
|
||||
sfx.save();
|
||||
L.particles.spawn(e.x + e.w / 2, e.y + e.h / 2, 18,
|
||||
@@ -326,8 +326,15 @@ export class Player {
|
||||
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 to = e.to;
|
||||
// 自制多面地图的跨面传送门:to = {face, door} —— 交给 main 切换面
|
||||
if (to && typeof to === 'object' && !('x' in to) && !('y' in to) && typeof to.face === 'number') {
|
||||
if (this.onPortal) this.onPortal(e);
|
||||
break;
|
||||
}
|
||||
// 原有逻辑(同关内传送 / 回出生点 / 绝对坐标 / pair 配对)
|
||||
const tgt = to === 'spawn' ? L.spawn
|
||||
: to && ('x' in to) ? { x: to.x * TILE, y: to.y * TILE }
|
||||
: (() => {
|
||||
const p = L.entities.find(o => o.type === 'warp' && o.idx === e.pair);
|
||||
return p ? { x: p.x + p.w / 2 - this.w / 2, y: p.y + p.h - this.h } : null;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 回归测试:多面地图的跨面传送门 + 存档点带面 + 内置关卡兼容
|
||||
const handlers = {};
|
||||
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
|
||||
globalThis.addEventListener = addL;
|
||||
globalThis.window = { addEventListener: addL };
|
||||
const key = (type, code) => (handlers[type] || []).forEach(fn => fn({ code, preventDefault() { }, repeat: false }));
|
||||
|
||||
const { Level } = await import('../js/level.js');
|
||||
const { Player } = await import('../js/player.js');
|
||||
const { Input } = await import('../js/input.js');
|
||||
|
||||
const W = 20, TILE = 32;
|
||||
let fail = 0;
|
||||
const err = m => { console.log(' ✗ ' + m); fail++; };
|
||||
const ok = m => console.log(' ✓ ' + m);
|
||||
|
||||
// 两面临门地图:A 面门 col5 指向 B 面门;B 面门 col10 指回 A 面门
|
||||
const gridA = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
|
||||
const gridB = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(9) + 'W' + '.'.repeat(W - 11) + '#', '#'.repeat(W), '#X' + '.'.repeat(W - 3) + '#', '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W), '#'.repeat(W)];
|
||||
const doorA = { id: 1, tx: 5, ty: 2, to: { face: 1, door: 1 } };
|
||||
const doorB = { id: 1, tx: 10, ty: 2, to: { face: 0, door: 1 } };
|
||||
const L0 = new Level({ name: 'A', type: 'custom', width: W, grid: gridA, doors: [doorA] }); L0.faceIndex = 0;
|
||||
const L1 = new Level({ name: 'B', type: 'custom', width: W, grid: gridB, doors: [doorB] }); L1.faceIndex = 1;
|
||||
|
||||
// 1) 门目标正确挂接
|
||||
{
|
||||
const w0 = L0.entities.find(e => e.type === 'warp');
|
||||
const w1 = L1.entities.find(e => e.type === 'warp');
|
||||
if (!w0 || !w1) err('门实体未生成');
|
||||
else {
|
||||
if (w0.to && w0.to.face === 1 && w0.to.door === 1 && w0.doorId === 1) ok('A 面门挂上跨面目标 {face:1,door:1}');
|
||||
else err('A 面门目标错误: ' + JSON.stringify(w0.to));
|
||||
if (w1.to && w1.to.face === 0 && w1.to.door === 1 && w1.doorId === 1) ok('B 面门挂上跨面目标 {face:0,door:1}');
|
||||
else err('B 面门目标错误: ' + JSON.stringify(w1.to));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 踩门触发 onPortal 钩子(main 据此切面)
|
||||
{
|
||||
const P = new Player(L0);
|
||||
let captured = null; P.onPortal = (e) => { captured = e; };
|
||||
const w0 = L0.entities.find(e => e.type === 'warp');
|
||||
P.x = w0.x; P.y = w0.y; P.vx = 0; P.vy = 0;
|
||||
L0.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (captured && captured.to && captured.to.face === 1) ok('踩到门触发 onPortal,目标面=1');
|
||||
else err('踩门未触发跨面 onPortal');
|
||||
}
|
||||
|
||||
// 3) 存档点带 faceIndex
|
||||
{
|
||||
const gridC = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + 'G' + '.'.repeat(W - 3) + '#', '#X' + '.'.repeat(W - 3) + '#'];
|
||||
const LC = new Level({ name: 'C', type: 'custom', width: W, grid: gridC, doors: [] }); LC.faceIndex = 2;
|
||||
const P = new Player(LC);
|
||||
const g = LC.entities.find(e => e.type === 'save');
|
||||
P.x = g.x; P.y = g.y;
|
||||
LC.update(1, P, null); P.update(1); Input.endFrame();
|
||||
if (P.savePoint && P.savePoint.face === 2) ok('存档点记录 face=2');
|
||||
else err('存档点未带 face,得到 ' + (P.savePoint && P.savePoint.face));
|
||||
}
|
||||
|
||||
// 4) 兼容:无 doors 的内置风格关卡,门仍走原有 pair 逻辑(to 为 null)
|
||||
{
|
||||
const gridD = ['#'.repeat(W), '#' + 'S' + '.'.repeat(W - 3) + '#', '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#'.repeat(W), '#' + '.'.repeat(4) + 'W' + '.'.repeat(W - 6) + '#', '#X' + '.'.repeat(W - 3) + '#'];
|
||||
const LD = new Level({ name: 'D', type: 'jump', width: W, grid: gridD });
|
||||
const ws = LD.entities.filter(e => e.type === 'warp');
|
||||
if (ws.length === 2 && ws[0].to === null && ws[1].to === null) ok('无 doors 时门 to=null(走原有 pair 逻辑,不退化)');
|
||||
else err('无 doors 时门目标异常: ' + JSON.stringify(ws.map(w => w.to)));
|
||||
}
|
||||
|
||||
console.log(fail ? `\n❌ ${fail} 项失败` : '\n✅ 多面地图/传送门 全部通过');
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in new issue
Block a user