fix: 优化跳跃和下落手感
This commit is contained in:
1 parent
ada64fe4be
commit
afe2f64b9b
25 files changed
+1197
-188
No files matched your search
+76
-32
@@ -2,12 +2,13 @@
|
||||
import { TILE } from './util.js';
|
||||
import { Save } from './save.js';
|
||||
import { makeCustomLevel } from './levels/index.js';
|
||||
import * as S from './sprites.js';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
|
||||
const PALETTE = [
|
||||
['?', '查看属性', '#8a8a9a'], ['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||||
export const PALETTE = [
|
||||
['#', '方块', '#6b6b7e'], ['@', '草地', '#4f9e46'], ['=', '砖块', '#a8543c'], ['M', '金属', '#5a6478'],
|
||||
['%', '冰面', '#8fd8f0'], ['~', '水', '#2a6fd8'], ['-', '单向台', '#9aa6bd'], ['V', '消失块', '#7a7a8c'],
|
||||
['^', '刺', '#c8c8d8'],
|
||||
['*', '美味水果', '#e03050'], ['S', '出生点', '#3ddc84'], ['G', '存档点', '#b06bff'], ['X', '终点', '#ffc63a'],
|
||||
@@ -42,10 +43,58 @@ const SCHEMA = {
|
||||
|
||||
function charLabel(ch) { const it = PALETTE.find(p => p[0] === ch); return it ? it[1] : ch; }
|
||||
|
||||
// 把某个方块按「游戏内真实外观」画到 ctx 的 (px,py) 处 —— 编辑器所见即所得
|
||||
function drawTileReal(ctx, ch, px, py, props, t) {
|
||||
const P = props || {};
|
||||
switch (ch) {
|
||||
case '#': S.drawBlock(ctx, px, py, 0); break; // 方块
|
||||
case '@': S.drawBlock(ctx, px, py, 1); break; // 草地
|
||||
case '=': S.drawBlock(ctx, px, py, 2); break; // 砖块
|
||||
case 'M': S.drawBlock(ctx, px, py, 3); break; // 金属
|
||||
case '%': S.drawIce(ctx, px, py); break; // 冰面
|
||||
case '~': S.drawWater(ctx, px, py, t); break; // 水
|
||||
case '-': S.drawOneWay(ctx, px, py); break; // 单向台
|
||||
case 'V': S.drawVanish(ctx, px, py, 0, t); break; // 消失块
|
||||
case '^': { // 刺:真实三角形,按道具方向
|
||||
const di = { up: 0, down: 1, left: 2, right: 3 }[P.dir || 'up'] || 0;
|
||||
S.drawSpike(ctx, px, py, di, t);
|
||||
break;
|
||||
}
|
||||
case '*': S.drawKiller(ctx, px, py, t); break; // 美味水果
|
||||
case 'D': S.drawDropSpike(ctx, { x: px, y: py, falling: false }, t); break; // 掉落刺
|
||||
case 'X': S.drawGoal(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 终点
|
||||
case 'G': S.drawSave(ctx, { x: px, y: py, w: TILE, h: TILE, active: false }, t); break; // 存档点
|
||||
case 'P': S.drawGun(ctx, { x: px, y: py }, t); break; // 手枪
|
||||
case 'F': S.drawApple(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 苹果
|
||||
case 'J': S.drawJumpRef(ctx, px, py, t); break; // 跳刷新
|
||||
case 'W': S.drawWarp(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 传送门
|
||||
case 'T': S.drawSign(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 提示牌
|
||||
case 'O': S.drawTurret(ctx, { x: px, y: py, dir: P.dir || 'left' }, t); break; // 炮台
|
||||
case 'C': S.drawCamera(ctx, { x: px, y: py, w: TILE, h: TILE }, t); break; // 摄像头
|
||||
case 'S': S.drawKid(ctx, { // 出生点:画一个小人
|
||||
x: px + (TILE - 11) / 2, y: py + (TILE - 21), w: 11, h: 21,
|
||||
facing: 1, grounded: true, hasGun: false, dead: false,
|
||||
}, t); break;
|
||||
default: drawMarker(ctx, px, py, ch); break; // BOSS / 触发器等无独立美术:色块+字母兜底
|
||||
}
|
||||
}
|
||||
|
||||
// 没有独立 sprite 的方块(BOSS / 触发器等):保留「色块 + 字母」保证可见
|
||||
function drawMarker(ctx, px, py, ch) {
|
||||
const item = PALETTE.find(p => p[0] === ch);
|
||||
const col = item ? item[2] : '#555';
|
||||
ctx.fillStyle = col; ctx.globalAlpha = 0.85;
|
||||
ctx.fillRect(px + 1, py + 1, TILE - 2, TILE - 2);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ch, px + TILE / 2, py + TILE / 2);
|
||||
}
|
||||
|
||||
// 编辑器中刺统一用 '^',方向存在 props.dir;导出时翻译成引擎的四个字符
|
||||
const SPIKE_CHAR = { up: '^', down: 'v', left: '<', right: '>' };
|
||||
const SPIKE_DIR = { '^': 'up', 'v': 'down', '<': 'left', '>': 'right' };
|
||||
const SPIKE_ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||||
|
||||
export const Editor = {
|
||||
api: {},
|
||||
@@ -70,10 +119,17 @@ export const Editor = {
|
||||
buildPalette() {
|
||||
const p = $('#palette');
|
||||
p.innerHTML = '';
|
||||
PALETTE.forEach(([ch, label, col]) => {
|
||||
PALETTE.forEach(([ch, label]) => {
|
||||
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 === '?' ? '⌖' : ch}</span><span>${label}</span>`;
|
||||
// 用真实绘制函数渲染缩略图,所见即所得
|
||||
const cv = document.createElement('canvas');
|
||||
cv.className = 'pal-cv'; cv.width = TILE; cv.height = TILE;
|
||||
drawTileReal(cv.getContext('2d'), ch, 0, 0, null, 0);
|
||||
d.appendChild(cv);
|
||||
const sp = document.createElement('span');
|
||||
sp.textContent = label;
|
||||
d.appendChild(sp);
|
||||
d.onclick = () => {
|
||||
this.sel = ch;
|
||||
Array.from(p.children).forEach(c => c.classList.remove('on'));
|
||||
@@ -95,13 +151,11 @@ export const Editor = {
|
||||
const f = this.curFace();
|
||||
const { tx, ty } = pos(e);
|
||||
if (tx < 0 || ty < 0 || tx >= f.w || ty >= f.h) return;
|
||||
// 选中该格(属性面板依据它渲染)
|
||||
// 无论手上拿的是什么方块,点击格子都选中它,属性面板立即显示该方块的属性
|
||||
this.selTile = { tx, ty };
|
||||
// 「查看属性」工具:只选中,不修改地图
|
||||
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);
|
||||
@@ -240,34 +294,24 @@ export const Editor = {
|
||||
ctx.fillStyle = '#101018'; ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.save();
|
||||
ctx.translate(-this.scroll, 0);
|
||||
const t = 0; // 编辑器为静态预览,不跑动画
|
||||
for (let y = 0; y < f.h; y++) {
|
||||
for (let x = 0; x < f.w; x++) {
|
||||
const ch = f.grid[y][x];
|
||||
const px = x * TILE, py = y * TILE;
|
||||
if (ch !== '.') {
|
||||
let col = '#555';
|
||||
const item = PALETTE.find(p => p[0] === ch);
|
||||
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;
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.07)';
|
||||
// 真实外观绘制(刺→三角形,方块→纹理,实体→对应精灵)
|
||||
if (ch !== '.') drawTileReal(ctx, ch, px, py, f.props[y][x] || null, t);
|
||||
// 淡网格线(编辑对位用)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px + .5, py + .5, TILE - 1, TILE - 1);
|
||||
if (ch !== '.') {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
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 (ch === 'W') {
|
||||
const d = f.doors.find(o => o.tx === x && o.ty === y);
|
||||
ctx.fillStyle = (d && d.to) ? '#bfffd0' : '#ffe6a0';
|
||||
ctx.font = 'bold 9px monospace';
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||
ctx.fillText('W' + (d ? d.id : ''), px + 2, py + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
// 输入:IWBT 经典键位(Shift 跳 / Z 跳 / X 射击 / R 重来)
|
||||
const BIND = {
|
||||
export const BIND = {
|
||||
left: ['ArrowLeft', 'KeyA'],
|
||||
right: ['ArrowRight', 'KeyD'],
|
||||
up: ['ArrowUp', 'KeyW'],
|
||||
@@ -12,6 +12,9 @@ const BIND = {
|
||||
menu: ['KeyQ'],
|
||||
};
|
||||
|
||||
// 出厂键位快照(供设置里的「恢复默认」使用)
|
||||
const DEFAULT_BIND = JSON.parse(JSON.stringify(BIND));
|
||||
|
||||
const down = new Set(); // 当前按住的原始 code
|
||||
const now = {}; // action 是否按下
|
||||
const prev = {}; // 上一帧
|
||||
@@ -75,6 +78,15 @@ addEventListener('keyup', e => {
|
||||
});
|
||||
addEventListener('blur', () => { down.clear(); refresh(); });
|
||||
|
||||
// ---------------- 键位自定义(设置界面调用) ----------------
|
||||
/** 返回一份出厂键位的深拷贝 */
|
||||
export function defaultBindings() { return JSON.parse(JSON.stringify(DEFAULT_BIND)); }
|
||||
/** 用一份 {action: [code,...]} 映射覆盖当前生效键位(设置确定后调用) */
|
||||
export function applyBindings(map) {
|
||||
if (!map) return;
|
||||
for (const act in map) if (act in BIND) BIND[act] = map[act].slice();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
for (const act in BIND) { now[act] = false; prev[act] = false; }
|
||||
export const ACTIONS = Object.keys(BIND);
|
||||
+33
-14
@@ -328,14 +328,14 @@ export class Level {
|
||||
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;
|
||||
const tr = spikeTri(t, tx * TILE, ty * TILE);
|
||||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||||
}
|
||||
}
|
||||
for (const e of this.entities) {
|
||||
if (e.type === 'dropspike' && e.falling) {
|
||||
const 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;
|
||||
const tr = spikeTri(T.SPIKE_L, e.x, e.y); // 与 drawDropSpike 的 dir=2(朝左)一致
|
||||
if (triRectOverlap(tr.ax, tr.ay, tr.bx, tr.by, tr.cx, tr.cy, x, y, w, h)) return true;
|
||||
}
|
||||
}
|
||||
for (const b of this.ebullets) {
|
||||
@@ -646,14 +646,33 @@ export class Level {
|
||||
}
|
||||
}
|
||||
|
||||
// 刺的判定盒:对齐齿尖、收窄到齿根之间(IWBT 三角形判定的矩形近似)。
|
||||
// 配合 11×21 的玩家判定盒,可以像素级贴刺、穿对刺缝。
|
||||
function spikeBox(t, tx, ty) {
|
||||
const x = tx * TILE, y = ty * TILE;
|
||||
if (t === T.SPIKE_U) return { x: x + 6, y: y + 8, w: TILE - 12, h: TILE - 8 }; // 齿尖 y+5,缝高 24
|
||||
if (t === T.SPIKE_D) return { x: x + 6, y: y + 1, w: TILE - 12, h: TILE - 9 }; // 齿尖 y+27
|
||||
if (t === T.SPIKE_L) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+5,缝宽 22
|
||||
if (t === T.SPIKE_R) return { x: x + 5, y: y + 6, w: TILE - 10, h: TILE - 12 }; // 齿尖 x+27
|
||||
return { x: x + 6, y: y + 6, w: TILE - 12, h: TILE - 10 }; // KILLER(美味水果)
|
||||
// 单三角形刺的顶点(与 sprites.drawSpike 完全一致)。
|
||||
// 尖端指向刺的方向,底边贴在相反一侧(地面 / 天花板 / 墙面)。
|
||||
function spikeTri(t, x, y) {
|
||||
const s = TILE;
|
||||
if (t === T.SPIKE_U) return { ax: x + s / 2, ay: y, bx: x, by: y + s, cx: x + s, cy: y + s };
|
||||
if (t === T.SPIKE_D) return { ax: x + s / 2, ay: y + s, bx: x, by: y, cx: x + s, cy: y };
|
||||
if (t === T.SPIKE_L) return { ax: x, ay: y + s / 2, bx: x + s, by: y, cx: x + s, cy: y + s };
|
||||
if (t === T.SPIKE_R) return { ax: x + s, ay: y + s / 2, bx: x, by: y, cx: x, cy: y + s };
|
||||
return { ax: x, ay: y, bx: x + s, by: y, cx: x, cy: y + s }; // 兜底(不应到达)
|
||||
}
|
||||
export { spikeBox };
|
||||
|
||||
// 三角形与轴对齐矩形的相交判定(分离轴定理,凸多边形充要条件)。
|
||||
function triRectOverlap(ax, ay, bx, by, cx, cy, rx, ry, rw, rh) {
|
||||
const rMinX = rx, rMaxX = rx + rw, rMinY = ry, rMaxY = ry + rh;
|
||||
const tMinX = Math.min(ax, bx, cx), tMaxX = Math.max(ax, bx, cx);
|
||||
const tMinY = Math.min(ay, by, cy), tMaxY = Math.max(ay, by, cy);
|
||||
if (tMaxX < rMinX || tMinX > rMaxX || tMaxY < rMinY || tMinY > rMaxY) return false; // AABB 快速排除
|
||||
const edges = [[bx - ax, by - ay], [cx - bx, cy - by], [ax - cx, ay - cy]];
|
||||
for (const [ex, ey] of edges) {
|
||||
const nx = -ey, ny = ex; // 边法线
|
||||
const t0 = nx * ax + ny * ay, t1 = nx * bx + ny * by, t2 = nx * cx + ny * cy;
|
||||
const tMin = Math.min(t0, t1, t2), tMax = Math.max(t0, t1, t2);
|
||||
const p0 = nx * rMinX + ny * rMinY, p1 = nx * rMaxX + ny * rMinY,
|
||||
p2 = nx * rMinX + ny * rMaxY, p3 = nx * rMaxX + ny * rMaxY;
|
||||
const rMin = Math.min(p0, p1, p2, p3), rMax = Math.max(p0, p1, p2, p3);
|
||||
if (tMax < rMin || rMax < tMin) return false; // 找到分离轴
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export { spikeTri, triRectOverlap };
|
||||
+24
-17
@@ -1,18 +1,20 @@
|
||||
// 跳刺(Needle)10 关 —— IWBT 经典密集针刺风格
|
||||
// 设计语法(已经 tools/solve.mjs 验证):
|
||||
// 刺毯缺口每 4 格一个(单跳节奏);钻石中心 = 地板上方 2~3 格;
|
||||
// 刺毯缺口每 3 格一个(跳 2 连刺 = 64px 单跳节奏);钻石中心 = 地板上方 2~3 格;
|
||||
// 垂刺离最低落脚点 5 格(单跳安全、二段跳必死);2 格高矮洞可用撞头跳;
|
||||
// 竖井台阶每级升 2~3 格横移 3 格内;钢丝柱间距 ≤4(单跳)/ ≤6(二段)。
|
||||
import { Grid } from './kit.js';
|
||||
import { vgap, hgap, diamond, spikeRun, block, corner, ceil, wall, well } from './needle.js';
|
||||
|
||||
// 刺毯:从 x0 起 n 格留落脚缺口。
|
||||
// 缺口间距在 4/3 格之间交替(跳 3 格 / 跳 4 格轮换),避免整关都是"每 4 格一跳"的单一套路;
|
||||
// 最远仍是 3 连刺(单跳极限),配合压顶垂刺时二段跳必死。
|
||||
// 缺口间距统一 3 格(跳 2 连刺 = 64px)。
|
||||
// 2026-10-23:JUMP 7.5 + GRAV_FALL 0.55 下单跳水平实测 118.8px(tools/jumpdist.mjs),
|
||||
// 但落地前一帧竖直速度已达 8.9px/帧,3 连刺(96px)的起拍窗被压到 0~1 帧(不可玩);
|
||||
// 2 连刺留 55px 射程余量,落地窗恢复充裕。井口/左端右行出口处的 3 连刺须显式拆断(见各关 gaps 注释)
|
||||
const carpet = (m, x0, y, n, gaps = []) => {
|
||||
const g = [];
|
||||
let i = 3, k = 0;
|
||||
while (i < n) { g.push(i); i += (k++ % 2 === 0 ? 4 : 3); }
|
||||
let i = 3;
|
||||
while (i < n) { g.push(i); i += 3; }
|
||||
return spikeRun(m, x0, y, n, '^', g.concat(gaps));
|
||||
};
|
||||
|
||||
@@ -58,7 +60,7 @@ export const JUMP_LEVELS = [
|
||||
wall(m, 79, R - 5, R - 5, '<'); wall(m, 74, R - 7, R - 7, '>');
|
||||
// --- 上层回廊(地板 row 12)x80-97 ---
|
||||
m.rect(80, R - 10, 18, 1, '#');
|
||||
carpet(m, 81, R - 11, 15, [0]);
|
||||
carpet(m, 81, R - 11, 15, [0, 8]); // 8=x89:给对刺门留左侧起拍位(新物理下跳台落点偏右,须能走进 x90)
|
||||
ceil(m, 81, 95, R - 16); // row 6 垂刺
|
||||
hgap(m, 92, R - 11); // 对刺门
|
||||
m.set(82, R - 11, 'G'); m.set(94, R - 11, 'G');
|
||||
@@ -82,7 +84,7 @@ export const JUMP_LEVELS = [
|
||||
// ---- L0 地板 row 48(向右:刺毯 + 三道上下夹缝)----
|
||||
m.rect(1, 48, 66, 1, '#');
|
||||
m.set(3, 47, 'S');
|
||||
carpet(m, 5, 47, 50);
|
||||
carpet(m, 5, 47, 50, [1, 18]); // 1=x6 出生旁缺口;18=x23 放宽落点窗(3 连刺落点贴右腰会被 6px 状态桶卡死)
|
||||
ceil(m, 5, 54, 43);
|
||||
vgap(m, 16, 46); vgap(m, 30, 46); vgap(m, 46, 46);
|
||||
m.set(11, 47, 'G'); m.set(39, 47, 'G');
|
||||
@@ -100,7 +102,7 @@ export const JUMP_LEVELS = [
|
||||
m.set(2, 37, '^'); m.set(8, 35, '^');
|
||||
// ---- L2 地板 row 32(向右,井底延伸到右井下方)----
|
||||
m.rect(9, 32, 57, 1, '#');
|
||||
carpet(m, 11, 31, 46);
|
||||
carpet(m, 11, 31, 46, [1, 11, 31]); // 1=x12 拆井口 3 连刺;11=x22/31=x42 是 hgap 夹缝通道列(必须为空,否则整门变 3 连刺、4 格跳超射程)
|
||||
ceil(m, 11, 54, 27);
|
||||
hgap(m, 22, 31); hgap(m, 42, 31);
|
||||
vgap(m, 32, 30); vgap(m, 50, 30);
|
||||
@@ -110,7 +112,7 @@ export const JUMP_LEVELS = [
|
||||
m.set(65, 29, '^'); m.set(58, 27, '^');
|
||||
// ---- L3 地板 row 24(向左,掉落刺;垂刺避开左井 x2..9)----
|
||||
m.rect(2, 24, 56, 1, '#');
|
||||
carpet(m, 10, 23, 44);
|
||||
carpet(m, 10, 23, 44, [0]); // 0=x10:拆掉左端 3 连刺(左行跳落点差 0.8px 蹭 x10 左坡;L1 同位置能过是因 vgap(12) 清空了 x10-14,L3 没有)
|
||||
ceil(m, 10, 54, 19);
|
||||
[14, 24, 34, 44].forEach(x => m.set(x, 18, 'D'));
|
||||
vgap(m, 40, 22); vgap(m, 22, 22);
|
||||
@@ -120,7 +122,7 @@ export const JUMP_LEVELS = [
|
||||
m.set(2, 21, '^'); m.set(8, 19, '^');
|
||||
// ---- L4 地板 row 16(向右到终点)----
|
||||
m.rect(9, 16, 57, 1, '#');
|
||||
carpet(m, 11, 15, 52);
|
||||
carpet(m, 11, 15, 52, [1, 41]); // 1=x12 拆井口 3 连刺;41=x52 是 hgap 夹缝通道列(必须为空)
|
||||
ceil(m, 11, 60, 11);
|
||||
hgap(m, 26, 15); vgap(m, 40, 14); hgap(m, 52, 15);
|
||||
[18, 34, 56].forEach(x => m.set(x, 10, 'D'));
|
||||
@@ -175,8 +177,8 @@ export const JUMP_LEVELS = [
|
||||
m.border('#');
|
||||
m.rect(1, R, W - 2, 1, '%'); // 冰地板
|
||||
m.set(3, R - 1, 'S');
|
||||
// --- x6-30 冰面刺坑 ---
|
||||
for (const [a, b] of [[7, 10], [13, 16], [19, 22], [25, 29]])
|
||||
// --- x6-30 冰面刺坑(3 格宽:新物理单跳 108.8px > 96px;旧 4~5 格坑跳不过)---
|
||||
for (const [a, b] of [[7, 9], [13, 15], [19, 21], [25, 27]])
|
||||
for (let x = a; x <= b; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
ceil(m, 6, 24, R - 5); // 压顶垂刺:冰面上禁二段跳
|
||||
m.set(12, R - 1, 'G');
|
||||
@@ -284,10 +286,11 @@ export const JUMP_LEVELS = [
|
||||
wall(m, 129, 26, 26, '<'); wall(m, 122, 23, 23, '>'); wall(m, 129, 20, 20, '<');
|
||||
// ============ L1 中层(地板 row 16,向左折返,延伸到两侧井底)============
|
||||
m.rect(1, 16, 122, 1, '#');
|
||||
carpet(m, 9, 15, 112);
|
||||
carpet(m, 9, 15, 112, [86]); // 86=x95:补 hgap(95) 通道列(否则门芯留一根 '^',GRAV_FALL 0.40 下回跳轨迹下降慢正好嵌进去)
|
||||
ceil(m, 9, 118, 10);
|
||||
vgap(m, 108, 14); vgap(m, 82, 14); vgap(m, 56, 14); vgap(m, 30, 14);
|
||||
hgap(m, 95, 15); hgap(m, 43, 15);
|
||||
m.set(44, 15, '.'); // 拆掉对刺门左刺'<':GRAV 0.33 下从 x45 起跳第 3 帧必被刺尖别住(起拍窗窄到 2px),清掉后留 ^+> 双刺门,整格起拍都能过
|
||||
block(m, 69, 15, 2); block(m, 17, 15, 2);
|
||||
m.set(113, 15, 'G'); m.set(63, 15, 'G'); m.set(21, 15, 'G');
|
||||
// ============ 左井 x2-7 → L2(右墙底部留 3 格入口,桥接块接 L2 地板边)============
|
||||
@@ -377,10 +380,13 @@ export const JUMP_LEVELS = [
|
||||
for (let x = 76; x <= 99; x++) { m.set(x, R, '.'); m.set(x, R + 1, '^'); }
|
||||
m.set(78, R - 2, '-'); m.set(79, R - 2, '-');
|
||||
m.set(83, R - 4, '-'); m.set(84, R - 4, '-');
|
||||
m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
|
||||
m.set(87, R - 6, '-'); m.set(88, R - 6, '-'); m.set(89, R - 6, '-');
|
||||
m.set(93, R - 4, '-'); m.set(94, R - 4, '-');
|
||||
m.set(97, R - 2, '-'); m.set(98, R - 2, '-');
|
||||
vgap(m, 86, R - 8); vgap(m, 93, R - 6);
|
||||
// x86 只留压顶刺:JUMP 8.0 下 apex 盒底 479 与 '^' 顶点 480 只差 1px,
|
||||
// 上升航线有 4 帧顶点落在盒内必死(8.5 时 apex 466 可从容擦过),故去掉地刺
|
||||
m.set(86, R - 9, 'v');
|
||||
vgap(m, 93, R - 6);
|
||||
m.set(101, R - 1, 'G');
|
||||
// --- x102-126 钻石收尾 ---
|
||||
carpet(m, 102, R - 1, 24, [0, 1, 20, 21]);
|
||||
@@ -405,7 +411,7 @@ export const JUMP_LEVELS = [
|
||||
// ============ L0 下层(地板 row 28)============
|
||||
m.rect(1, 28, W - 2, 1, '#');
|
||||
m.set(3, 27, 'S');
|
||||
carpet(m, 5, 27, 116);
|
||||
carpet(m, 5, 27, 116, [54]); // 54=x59:拆开矮洞入口的四格跳(洞顶 row24 会挡跳的横向漂移)
|
||||
ceil(m, 5, 120, 22);
|
||||
vgap(m, 14, 26); vgap(m, 38, 26);
|
||||
vgap(m, 86, 26); vgap(m, 110, 26);
|
||||
@@ -427,7 +433,7 @@ export const JUMP_LEVELS = [
|
||||
wall(m, 126, 24, 24, '<'); wall(m, 119, 21, 21, '>'); wall(m, 126, 18, 18, '<');
|
||||
// ============ L1 上层(地板 row 14,向左折返,延伸到井底)============
|
||||
m.rect(2, 14, 118, 1, '#');
|
||||
carpet(m, 9, 13, 108);
|
||||
carpet(m, 9, 13, 108, [27, 59]); // 27=x36 / 59=x68:补对刺门通道列(否则整门是一面 3 连刺,往回跳必被 < 刺别住)
|
||||
ceil(m, 9, 116, 8);
|
||||
hgap(m, 100, 13); hgap(m, 68, 13); hgap(m, 36, 13);
|
||||
vgap(m, 84, 12); vgap(m, 52, 12); vgap(m, 20, 12);
|
||||
@@ -498,6 +504,7 @@ export const JUMP_LEVELS = [
|
||||
wires.push(wx);
|
||||
for (const s of steps) { wx -= s; if (wx < 14) break; wires.push(wx); }
|
||||
wires.forEach((x, i) => m.vline(x, [6, 5, 7, 6, 7, 5][i % 6], 7, '#'));
|
||||
m.vline(55, 5, 7, '#'); // 过渡柱:x58→x52 原为 6 格跳,HSPD 2.6 下"晚二段"落点整体左移 8px 掉过柱顶摔死;x55 拆成 3+3 格两跳(dj=12 轨迹实测两跳都能精确接住)
|
||||
// 存档放在两根钢丝顶上
|
||||
const g1 = wires[6], g2 = wires[Math.floor(wires.length / 2) + 4];
|
||||
const topOf = i => [6, 5, 7, 6, 7, 5][i % 6];
|
||||
|
||||
+4
-4
@@ -290,6 +290,7 @@ function loop(now) {
|
||||
}
|
||||
|
||||
// ---------------- 启动 ----------------
|
||||
let settingsFrom = 'scr-title'; // 进入设置前的界面,确定后返回
|
||||
Save.load();
|
||||
Audio2.enabled = Save.data.audio !== false;
|
||||
|
||||
@@ -297,10 +298,9 @@ UI.init({
|
||||
play: (lv) => { G.fromEditor = false; startLevel(lv, lv.builtin === false ? -1 : (lv.index ?? -1)); },
|
||||
levels: toLevels,
|
||||
manage: () => UI.setManage(!UI.manage),
|
||||
help: () => UI.show('scr-help'),
|
||||
editor: toEditor,
|
||||
settings: () => { Settings.enter(); UI.show('scr-settings'); },
|
||||
'set-ok': () => Settings.confirm(),
|
||||
settings: () => { settingsFrom = UI.cur; Settings.enter(); UI.show('scr-settings'); },
|
||||
'set-ok': () => { Settings.confirm(); UI.show(settingsFrom); },
|
||||
'set-reset': () => Settings.reset(),
|
||||
back: toTitle,
|
||||
resume,
|
||||
@@ -325,7 +325,7 @@ addEventListener('keydown', unlock);
|
||||
|
||||
// 移动端虚拟手柄
|
||||
Touch.init({ pause, suicide, shoot });
|
||||
Settings.init();
|
||||
Settings.init(useTouch);
|
||||
|
||||
UI.show('scr-title');
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
+26
-15
@@ -6,11 +6,16 @@ import * as S from './sprites.js';
|
||||
import { T } from './level.js';
|
||||
|
||||
export const PHY = {
|
||||
GRAV: 0.4,
|
||||
GRAV: 0.25, // 上升段重力:小 = 跳跃飘
|
||||
GRAV_FALL: 0.32, // 下落段重力:小 = 下坠轻、飘落感(0.35→0.32,用户反馈"下落有点沉")
|
||||
GRAV_DJ: 0.35, // 二段跳上升段专属重力:比 GRAV 稍重——初速 6.7 与一段 6.6 一致,配 0.35 才能又冲(不起悬停)又只有 4.5 格总高
|
||||
MAXFALL: 9,
|
||||
JUMP: 8.5,
|
||||
DJUMP: 7.6,
|
||||
HSPD: 3,
|
||||
JUMP: 6.6,
|
||||
DJUMP: 6.7, // 二段跳冲量:起跳初速与一段 JUMP 6.6 几乎一致(起跳"嘭"感同款);顶点接二段总高 = 4.51 格
|
||||
DJ_INERTIA: 0, // 二段跳时下落速度对冲量的抵消比例:0=任何时机接二段都满力起跳(初速恒 -DJUMP,同 IWBT 原版),
|
||||
// 1=全额抵消(旧 bug:顶点晚几帧接二段直接归零)。0.35/0.5 都被用户反馈"好沉+短"——起跳初速被下落速度
|
||||
// 吃掉才是"沉"的根源(初速从 6.7 掉到 3~4,起跳那一下没劲),与上升重力无关。
|
||||
HSPD: 2.6,
|
||||
WATER_GRAV: 0.16,
|
||||
WATER_FALL: 2.6,
|
||||
WATER_RISE: 0.42,
|
||||
@@ -23,6 +28,7 @@ export const PHY = {
|
||||
SLIDE_BRAKE: 0.45, // 滑行中按反向键的刹车
|
||||
SLIDE_MIN: 0.35, // 冰面上攒到多大速度才算进入滑行状态
|
||||
SLIDE_STOP: 0.05, // 低于此速度惯性结束
|
||||
ICE_TOP: 1.6, // 冰面最高速度 = HSPD × ICE_TOP(调大=冰上能滑得更快,留更猛的惯性)
|
||||
};
|
||||
|
||||
export class Player {
|
||||
@@ -38,12 +44,12 @@ export class Player {
|
||||
this.x = sp.x; this.y = sp.y;
|
||||
this.vx = 0; this.vy = 0;
|
||||
this.jumps = 0;
|
||||
this.djUp = false;
|
||||
this.grounded = false;
|
||||
this.facing = 1;
|
||||
this.alive = true;
|
||||
this.dead = false;
|
||||
this.deadT = 0;
|
||||
this.squash = 1;
|
||||
this.inWater = false;
|
||||
this.animT = 0;
|
||||
this.shootCool = 0;
|
||||
@@ -78,7 +84,8 @@ export class Player {
|
||||
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; this.sliding = false;
|
||||
this.djUp = false;
|
||||
this.shootCool = 0; this.sliding = false;
|
||||
this.deadT = 0;
|
||||
// 安全兜底:万一重生点被地形/消失块堵住,向上顶出,别一复活就卡死
|
||||
let guard = 0;
|
||||
@@ -107,7 +114,7 @@ export class Player {
|
||||
if (moveL && !moveR) target = -PHY.HSPD;
|
||||
else if (moveR && !moveL) target = PHY.HSPD;
|
||||
|
||||
const ICE_MAX = PHY.HSPD * 1.35;
|
||||
const ICE_MAX = PHY.HSPD * PHY.ICE_TOP;
|
||||
const decel = f => {
|
||||
if (Math.abs(this.vx) <= f) this.vx = 0;
|
||||
else this.vx -= sgn(this.vx) * f;
|
||||
@@ -155,18 +162,22 @@ export class Player {
|
||||
if (Input.is('jump')) this.vy -= PHY.WATER_RISE;
|
||||
if (Input.is('down')) this.vy += 0.25;
|
||||
this.vy = clamp(this.vy, -3.2, PHY.WATER_FALL);
|
||||
this.jumps = 0;
|
||||
this.jumps = 0; this.djUp = false;
|
||||
} else {
|
||||
if (Input.hit('jump')) {
|
||||
if (this.grounded || this.jumps === 0) {
|
||||
this.vy = -PHY.JUMP; this.jumps = 1; this.grounded = false;
|
||||
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;
|
||||
// 二段跳冲量叠减当前速度:上升中按 = 原版语义(直接获得 -DJUMP);
|
||||
// 下落中按 = 下落惯性只抵消一部分冲量(DJUMP 前乘 DJ_INERTIA)——
|
||||
// 全额抵消会让"满力一段跳到顶点、晚几帧接二段"的二段塌到接近 0(非常短),
|
||||
// 只抵消一半则顶点附近接二段保持满力,摔得快时才明显变弱
|
||||
this.vy = Math.max(this.vy, 0) * PHY.DJ_INERTIA - PHY.DJUMP; this.jumps = 2;
|
||||
this.djUp = this.vy < 0; // 二段上升段启用专属轻重力(与一段同款飘)
|
||||
sfx.djump();
|
||||
L.particles.spawn(this.cx, this.cy, 10,
|
||||
{ colors: ['#fff', '#9ad8ff'], speed: 2.2, life: 20, size: 2, spread: Math.PI * 2 });
|
||||
@@ -174,7 +185,9 @@ export class Player {
|
||||
}
|
||||
// 可变跳跃高度
|
||||
if (!Input.is('jump') && this.vy < -3) this.vy = -3;
|
||||
this.vy = Math.min(this.vy + PHY.GRAV, PHY.MAXFALL);
|
||||
// 上升轻、下落重:分手感。二段上升段用更轻的 GRAV_DJ(初速小,同款飘)
|
||||
this.vy = Math.min(this.vy + (this.vy < 0 ? (this.djUp ? PHY.GRAV_DJ : PHY.GRAV) : PHY.GRAV_FALL), PHY.MAXFALL);
|
||||
if (this.djUp && this.vy >= 0) this.djUp = false; // 二段到顶,交回普通重力
|
||||
}
|
||||
|
||||
// ---- 站在移动平台上则跟随 ----
|
||||
@@ -226,10 +239,8 @@ export class Player {
|
||||
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.jumps = 0; this.djUp = false;
|
||||
}
|
||||
this.squash += (1 - this.squash) * 0.22;
|
||||
|
||||
// ---- 滑行反馈:超速滑行(冰上,或刚滑出冰块仍带惯性)时脚下溅起冰屑 ----
|
||||
if (this.sliding && this.grounded && Math.abs(this.vx) > PHY.HSPD && this.animT % 5 === 0) {
|
||||
|
||||
+168
-11
@@ -1,5 +1,8 @@
|
||||
// 设置:用户名 + 手机虚拟手柄按钮的自定义(位置/大小,拖动调整)
|
||||
// 设置:用户名 + 设备相关自定义
|
||||
// · PC(电脑端)→ 键盘按键映射
|
||||
// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
|
||||
import { Save } from './save.js';
|
||||
import { applyBindings, defaultBindings, BIND } from './input.js';
|
||||
|
||||
// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
|
||||
// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
|
||||
@@ -13,21 +16,37 @@ const BUTTONS = [
|
||||
{ key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } },
|
||||
];
|
||||
|
||||
// 设置界面里可重绑的键盘动作(标签 + action)
|
||||
const KEY_ACTIONS = [
|
||||
['left', '左移'], ['right', '右移'],
|
||||
['up', '上(上浮)'],
|
||||
['jump', '跳跃'], ['shoot', '射击'],
|
||||
['suicide', '自杀 / 重生'], ['restart', '整关重来'],
|
||||
['pause', '暂停'],
|
||||
];
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
const DEFAULT_TOUCH = {};
|
||||
BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); });
|
||||
const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH) });
|
||||
const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH), keys: defaultBindings() });
|
||||
|
||||
// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
|
||||
let work = makeDefaults();
|
||||
let wrap = null;
|
||||
let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
|
||||
let listening = null; // 重绑监听:{act, idx}
|
||||
let conflict = null; // 冲突警告:{code, label, act}
|
||||
const els = {};
|
||||
let keysBox = null; // 键盘映射容器
|
||||
|
||||
/** 读取/补全存档里的设置对象(保证 touch 字段齐全) */
|
||||
/** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
|
||||
function meta() {
|
||||
if (!Save.data.settings) Save.data.settings = { username: '' };
|
||||
if (!Save.data.settings.touch) Save.data.settings.touch = clone(DEFAULT_TOUCH);
|
||||
return Save.data.settings;
|
||||
if (!Save.data.settings) Save.data.settings = {};
|
||||
const s = Save.data.settings;
|
||||
if (typeof s.username !== 'string') s.username = '';
|
||||
if (!s.touch) s.touch = clone(DEFAULT_TOUCH);
|
||||
if (!s.keys) s.keys = defaultBindings();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
|
||||
@@ -59,13 +78,135 @@ function showUsername() {
|
||||
if (el) el.textContent = '玩家:' + (meta().username || '无名氏');
|
||||
}
|
||||
|
||||
/** 把键盘 code 转成可读标签 */
|
||||
function keyLabel(code) {
|
||||
const M = {
|
||||
Space: '空格', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||||
AltLeft: 'Alt', AltRight: 'Alt', Tab: 'Tab', Backspace: '⌫', Escape: 'Esc', Enter: 'Enter',
|
||||
ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
|
||||
MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: '菜单',
|
||||
};
|
||||
if (M[code]) return M[code];
|
||||
if (code.startsWith('Key')) return code.slice(3);
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
if (code.startsWith('Numpad')) return '小键' + code.slice(6);
|
||||
if (/^F\d{1,2}$/.test(code)) return code;
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 构建设置界面里的键盘映射行(点击按键后按新键重绑) */
|
||||
function renderKeys() {
|
||||
if (!keysBox) return;
|
||||
keysBox.innerHTML = '';
|
||||
// 冲突警告横幅(始终置于面板顶部,醒目)
|
||||
if (conflict) {
|
||||
const w = document.createElement('div');
|
||||
w.className = 'kwarb';
|
||||
w.textContent = `「${keyLabel(conflict.code)}」已被【${conflict.label}】占用,请换一个键(Esc 取消)`;
|
||||
keysBox.appendChild(w);
|
||||
}
|
||||
for (const [act, label] of KEY_ACTIONS) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'krow';
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'kact'; nm.textContent = label;
|
||||
const keys = document.createElement('div');
|
||||
keys.className = 'kkeys';
|
||||
(work.keys[act] || []).forEach((code, idx) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
const isListen = listening && listening.act === act && listening.idx === idx;
|
||||
chip.className = 'kchip' + (isListen ? ' listen' : '');
|
||||
chip.dataset.kact = act; chip.dataset.kidx = idx;
|
||||
chip.textContent = keyLabel(code);
|
||||
const rm = document.createElement('b');
|
||||
rm.className = 'krm'; rm.dataset.krm = act; rm.dataset.kidx = idx; rm.textContent = '×';
|
||||
chip.appendChild(rm);
|
||||
keys.appendChild(chip);
|
||||
});
|
||||
const add = document.createElement('button');
|
||||
add.type = 'button';
|
||||
add.className = 'kadd'; add.dataset.kadd = act; add.textContent = '+ 添加';
|
||||
keys.appendChild(add);
|
||||
if (listening && listening.act === act) {
|
||||
const tip = document.createElement('span');
|
||||
const isClash = conflict && conflict.act === act;
|
||||
tip.className = 'klisten' + (isClash ? ' bad' : '');
|
||||
tip.textContent = isClash ? '该键冲突,请换一个键(Esc 取消)' : '按下一个键…(Esc 取消)';
|
||||
keys.appendChild(tip);
|
||||
}
|
||||
row.appendChild(nm); row.appendChild(keys);
|
||||
keysBox.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测 code 是否已绑定到其它动作(返回冲突动作的标签,否则 null) */
|
||||
function conflictInfo(code, act, idx) {
|
||||
for (const [a, label] of KEY_ACTIONS) {
|
||||
const arr = work.keys[a] || [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] === code && !(a === act && i === idx)) return label;
|
||||
}
|
||||
}
|
||||
// 不可重绑的系统动作也算冲突(如「菜单」用的 KeyQ)
|
||||
if (BIND.menu && BIND.menu.includes(code)) return '菜单';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 重绑监听:捕获下一次按键并写入 work.keys(检测到冲突则拒绝并提示) */
|
||||
function onCaptureKey(e) {
|
||||
if (!listening) return;
|
||||
e.preventDefault();
|
||||
if (e.code === 'Escape') { listening = null; conflict = null; renderKeys(); return; }
|
||||
const { act, idx } = listening;
|
||||
const clash = conflictInfo(e.code, act, idx);
|
||||
if (clash) { // 该键已被其它动作占用 → 拒绝并提示
|
||||
conflict = { code: e.code, label: clash, act };
|
||||
renderKeys();
|
||||
return; // 停留在监听态,等待换键或 Esc
|
||||
}
|
||||
if (idx >= (work.keys[act] || []).length) (work.keys[act] = work.keys[act] || []).push(e.code);
|
||||
else work.keys[act][idx] = e.code;
|
||||
conflict = null;
|
||||
listening = null;
|
||||
renderKeys();
|
||||
}
|
||||
|
||||
/** 给键盘映射容器绑定点击委托(重绑 / 添加 / 删除) */
|
||||
let keysWired = false;
|
||||
function wireKeys() {
|
||||
if (!keysBox || keysWired) return;
|
||||
keysWired = true;
|
||||
keysBox.addEventListener('click', (e) => {
|
||||
const rm = e.target.closest('.krm');
|
||||
if (rm) {
|
||||
const a = rm.dataset.krm, i = +rm.dataset.kidx;
|
||||
work.keys[a].splice(i, 1);
|
||||
if (listening && listening.act === a) listening = null;
|
||||
renderKeys();
|
||||
return;
|
||||
}
|
||||
const chip = e.target.closest('.kchip');
|
||||
if (chip) { listening = { act: chip.dataset.kact, idx: +chip.dataset.kidx }; renderKeys(); return; }
|
||||
const add = e.target.closest('.kadd');
|
||||
if (add) { listening = { act: add.dataset.kadd, idx: 1e9 }; renderKeys(); return; }
|
||||
});
|
||||
}
|
||||
|
||||
export const Settings = {
|
||||
init() {
|
||||
/** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
|
||||
init(isTouch) {
|
||||
pcMode = !isTouch;
|
||||
wrap = document.getElementById('touch-preview');
|
||||
keysBox = document.getElementById('set-keys');
|
||||
work = clone(meta());
|
||||
this.build();
|
||||
renderKeys();
|
||||
wireKeys();
|
||||
showUsername();
|
||||
apply();
|
||||
applyBindings(meta().keys);
|
||||
document.addEventListener('keydown', onCaptureKey);
|
||||
},
|
||||
|
||||
/** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
|
||||
@@ -86,38 +227,54 @@ export const Settings = {
|
||||
}
|
||||
},
|
||||
|
||||
/** 打开设置界面:从存档同步工作副本与输入框 */
|
||||
/** 打开设置界面:从存档同步工作副本与输入框,并按设备切换面板 */
|
||||
enter() {
|
||||
const s = meta();
|
||||
work = { username: s.username || '', touch: clone(s.touch) };
|
||||
work = { username: s.username || '', touch: clone(s.touch), keys: clone(s.keys) };
|
||||
const inp = document.getElementById('set-username');
|
||||
if (inp) inp.value = work.username;
|
||||
listening = null;
|
||||
conflict = null;
|
||||
this.build();
|
||||
renderKeys();
|
||||
// 按设备显示对应面板:PC→键盘映射;移动端→手柄按钮
|
||||
const keysRow = document.getElementById('set-keys-row');
|
||||
const touchRow = document.getElementById('set-touch-row');
|
||||
if (keysRow) keysRow.classList.toggle('hidden', !pcMode);
|
||||
if (touchRow) touchRow.classList.toggle('hidden', pcMode);
|
||||
},
|
||||
|
||||
/** 确定:保存用户名与手柄布局到存档 */
|
||||
/** 确定:保存用户名 / 手柄布局 / 键位到存档,并应用,然后由调用方返回上一页 */
|
||||
confirm() {
|
||||
const s = meta();
|
||||
const inp = document.getElementById('set-username');
|
||||
work.username = (inp ? inp.value : '').trim().slice(0, 16);
|
||||
s.username = work.username;
|
||||
s.touch = clone(work.touch);
|
||||
s.keys = clone(work.keys);
|
||||
Save.flush();
|
||||
showUsername();
|
||||
apply();
|
||||
applyBindings(s.keys);
|
||||
},
|
||||
|
||||
/** 恢复默认:布局与用户名全部回到出厂值(仅影响工作副本,需点确定才落盘) */
|
||||
/** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
|
||||
reset() {
|
||||
work = makeDefaults();
|
||||
listening = null;
|
||||
conflict = null;
|
||||
const inp = document.getElementById('set-username');
|
||||
if (inp) inp.value = '';
|
||||
this.build();
|
||||
renderKeys();
|
||||
},
|
||||
|
||||
/** 供调试 / 测试使用 */
|
||||
_state() { return work; },
|
||||
_els() { return els; },
|
||||
_pcMode() { return pcMode; },
|
||||
_conflict() { return conflict; },
|
||||
_listening() { return listening; },
|
||||
};
|
||||
|
||||
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
|
||||
|
||||
+28
-28
@@ -8,7 +8,7 @@ const C = {
|
||||
metal: '#5a6478', metalDark: '#333a4a',
|
||||
ice: '#8fd8f0', iceDark: '#4a90b8',
|
||||
water: '#2a6fd8',
|
||||
spike: '#c8c8d8', spikeDark: '#5a5a6a',
|
||||
spike: '#cfdde9', spikeDark: '#a8bccf',
|
||||
kid: '#d94a55', kidDark: '#8e2530', skin: '#f5cfa8', hair: '#5a3a22', pant: '#2c3a7a',
|
||||
save: '#b06bff', goal: '#ffc63a', gun: '#b8b8c8', apple: '#e03050',
|
||||
};
|
||||
@@ -77,33 +77,34 @@ export function drawOneWay(ctx, x, y) {
|
||||
for (let i = 0; i < 8; i++) px(ctx, x + i * 4, y + 6, 2, 3, C.metalDark);
|
||||
}
|
||||
|
||||
// 单个三角形刺:尖端指向 dir,底边贴在相反一侧(与 level.js 的 spikeTri 顶点完全一致)。
|
||||
// 外观对齐经典 I wanna 素材:冰蓝白渐变的干净大三角,占满整格。
|
||||
export function drawSpike(ctx, x, y, dir, t) {
|
||||
const s = TILE;
|
||||
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');
|
||||
let ax, ay, bx, by, cx, cy;
|
||||
if (dir === 0) { // 朝上(地面上)
|
||||
ax = x + s / 2; ay = y; bx = x; by = y + s; cx = x + s; cy = y + s;
|
||||
} else if (dir === 1) { // 朝下(天花板)
|
||||
ax = x + s / 2; ay = y + s; bx = x; by = y; cx = x + s; cy = y;
|
||||
} else if (dir === 2) { // 朝左(贴右墙)
|
||||
ax = x; ay = y + s / 2; bx = x + s; by = y; cx = x + s; cy = y + s;
|
||||
} else { // 朝右(贴左墙)
|
||||
ax = x + s; ay = y + s / 2; bx = x; by = y; cx = x; cy = y + s;
|
||||
}
|
||||
}
|
||||
function tri(ctx, x1, y1, x2, y2, x3, y3) {
|
||||
// 冰蓝白渐变:尖端最亮,往底边过渡到淡蓝
|
||||
let g;
|
||||
if (dir === 0) { g = ctx.createLinearGradient(x, y, x, y + s); }
|
||||
else if (dir === 1) { g = ctx.createLinearGradient(x, y + s, x, y); }
|
||||
else if (dir === 2) { g = ctx.createLinearGradient(x, y, x + s, y); }
|
||||
else { g = ctx.createLinearGradient(x + s, y, x, y); }
|
||||
g.addColorStop(0, '#f4f9fd');
|
||||
g.addColorStop(0.55, '#cfdde9');
|
||||
g.addColorStop(1, '#a8bccf');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(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();
|
||||
ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.lineTo(cx, cy); ctx.closePath();
|
||||
ctx.fillStyle = g; ctx.fill();
|
||||
// 淡蓝描边(参考图的柔和轮廓)
|
||||
ctx.strokeStyle = 'rgba(150,175,200,.9)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
|
||||
/** 全致死方块(美味水果外观) */
|
||||
@@ -152,11 +153,10 @@ 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 正常
|
||||
// 不做任何 squash/stretch 形变:起跳与落地都保持原始体型,
|
||||
// 避免身体忽高忽低被感知为"上下晃动"(2026-10-23 用户两次反馈后彻底移除)
|
||||
ctx.save();
|
||||
ctx.translate(x + w / 2, y + h);
|
||||
ctx.scale(1 / sq, sq);
|
||||
ctx.translate(-w / 2, -h);
|
||||
ctx.translate(x, y);
|
||||
|
||||
const face = p.facing; // 1 右 -1 左
|
||||
// 身体(红衣)
|
||||
|
||||
@@ -6,7 +6,7 @@ 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-settings', 'scr-pause', 'scr-result', 'scr-editor'];
|
||||
const SCREENS = ['scr-title', 'scr-levels', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
|
||||
const TABS = [
|
||||
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user