纯前端实现光路解谜:射线步进、镜片转向与关卡状态机
光路解谜看起来像是简单的画线游戏,真正落到网页里,核心其实是一个很克制的小型模拟器:光从固定方向出发,在离散网格中前进;遇到镜片时改变方向;撞墙后终止;抵达目标才算通关。这篇笔记记录我用原生 JavaScript 实现一个 7×7 光路解谜小游戏时,对射线步进、镜片交互和关卡状态的整理。
一、用网格而不是浮点坐标
如果让光线在 Canvas 上按像素移动,碰撞判断会很快变成一堆坐标比较。关卡本身就是格子棋盘,因此我把所有逻辑都放在整数网格里。每格记录墙、镜片、光源、目标或空地,光线状态只保留一个位置和一个方向:
const directions = {
up: { x: 0, y: -1 },
right: { x: 1, y: 0 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
};
function nextCell(position, direction) {
const delta = directions[direction];
return { x: position.x + delta.x, y: position.y + delta.y };
}
渲染层可以把一格画成几十像素,但模拟层始终只认行列编号。这样既能支持桌面和手机的不同尺寸,也让预览与正式游戏共用同一套判定。
二、镜片只负责改变方向
关卡中有两种镜片:/ 与 \。它们在视觉上是两条短线,逻辑上则是一张方向映射表。光线进入镜片后立即转向,然后继续走下一格:
const mirrors = {
'/': {
right: 'up',
left: 'down',
up: 'right',
down: 'left',
},
'\\': {
right: 'down',
left: 'up',
up: 'left',
down: 'right',
},
};
function turn(mirror, direction) {
return mirrors[mirror][direction];
}
把规则做成表有三个好处:新增镜片类型时不用改主循环;单元测试可以直接覆盖所有方向组合;动画也能先求出整条路线,再根据路线长度决定播放速度。
三、射线步进必须有明确终点
光线每一步都走一格,但循环不能只靠“碰到目标就停”。最常见的漏洞是两张镜片把光来回反射,形成无限循环。我的做法是同时检查边界、障碍和重复状态:
function trace(level) {
const seen = new Set();
let ray = { ...level.source };
const path = [];
for (let step = 0; step < 100; step++) {
ray = nextCell(ray, ray.direction);
if (!inside(level, ray)) return { path, hit: 'wall' };
const key = `${ray.x},${ray.y},${ray.direction}`;
if (seen.has(key)) return { path, hit: 'loop' };
seen.add(key);
path.push({ ...ray });
const cell = level.at(ray.x, ray.y);
if (cell.type === 'wall') return { path, hit: 'wall' };
if (cell.type === 'target') return { path, hit: 'target' };
if (cell.type === 'mirror') {
ray.direction = turn(cell.value, ray.direction);
}
}
return { path, hit: 'limit' };
}
位置和方向一起组成状态键,是因为同一格从不同方向射入可能得到完全不同的结果。记录它既能识别死循环,也方便以后做“路径预测”或“提示下一步”的功能。
四、点击镜片,而不是拖动画线
移动端拖拽很容易和页面滚动冲突,因此交互被我简化为点击:点一次镜片旋转 90 度,点击空白区域不产生任何状态变化。每次旋转后都重新计算光路,并用短动画逐格展示:
function rotateAt(x, y) {
const cell = level.at(x, y);
if (cell.type !== 'mirror') return false;
cell.value = cell.value === '/' ? '\\' : '/';
const result = trace(level);
renderPath(result.path, result.hit === 'target');
return true;
}
光线动画以 requestAnimationFrame 驱动,每帧点亮路线中的若干格。动画期间暂时忽略新的镜片点击,避免上一条射线还没画完就被新状态覆盖。等动画结束,再开放下一次操作。
五、关卡状态与完成判定
一个小游戏能持续玩下去,不能只有“赢了”两个状态。我的关卡对象保留当前地图、镜片旋转次数、光路结果和是否完成:
const levelState = {
index: 0,
moves: 0,
completed: false,
path: [],
};
function evaluate(result) {
levelState.path = result.path;
levelState.completed = result.hit === 'target';
if (levelState.completed) showCompletion(levelState.moves);
}
切换关卡时使用深拷贝后的初始地图,而不是直接复用上一关的格子对象。否则玩家旋转过的镜片会被带进下一局,调试时看起来像是“关卡数据丢失”,实际只是引用被共享了。
六、小结
光路解谜的骨架并不复杂:整数网格负责空间,方向映射表负责规则,带状态去重的射线步进负责安全终止,关卡对象负责保存进度。把这几层拆开后,新增镜片、换主题或制作移动端布局都不会牵动核心判定。对于纯前端小游戏来说,先把规则写成可预测的小函数,再考虑动画和装饰,通常比反过来更省时间。
- 点赞
- 收藏
- 关注作者
评论(0)