387 lines
14 KiB
JavaScript
387 lines
14 KiB
JavaScript
import { hexPoints, position, trackPoints } from '../../src/path-math.js';
|
|
import { apronDistance, bayNormal, boardRadius, trayHull, waitingPose } from '../airport/core.js';
|
|
import { checkRelocate, COLORS, Inbound, SIDES, catalog } from './core.js';
|
|
import { LEVELS } from './levels.js';
|
|
|
|
const $ = id => document.getElementById(id);
|
|
const canvas = $('board'), ctx = canvas.getContext('2d');
|
|
const view = { dpr: 1, w: 1, h: 1, ox: 0, oy: 0, size: 1 };
|
|
let index = Math.max(0, Math.min(LEVELS.length - 1, (Number(new URLSearchParams(location.search).get('level')) || 1) - 1));
|
|
let game, mouths, preview, previewSteps, selected, anim, feedback, poses;
|
|
|
|
function load(next) {
|
|
index = (next + LEVELS.length) % LEVELS.length;
|
|
game = new Inbound(LEVELS[index]);
|
|
mouths = catalog(LEVELS[index]);
|
|
preview = game.state.dial;
|
|
previewSteps = 0;
|
|
selected = null;
|
|
anim = feedback = null;
|
|
history.replaceState(null, '', `?level=${index + 1}`);
|
|
sync();
|
|
draw();
|
|
}
|
|
|
|
function sync() {
|
|
const level = LEVELS[index];
|
|
$('level-title').textContent = `第 ${index + 1} 关 · ${level.name}`;
|
|
$('notice').textContent = level.lesson;
|
|
$('stars').textContent = String(game.state.stars);
|
|
$('standard').textContent = `/ ${level.standard}`;
|
|
const done = game.state.planes.filter(plane => plane.state === 'parked').length;
|
|
$('progress').textContent = `${done} / ${game.state.planes.length}`;
|
|
$('confirm').disabled = previewSteps === 0 || !!anim;
|
|
$('confirm').textContent = previewSteps === 0 ? '确认这一转' : `确认 ${Math.abs(previewSteps)} 格`;
|
|
$('undo').disabled = !game.history.length || !!anim;
|
|
$('left').disabled = $('right').disabled = !!anim;
|
|
const plane = game.state.planes.find(item => item.id === selected);
|
|
$('relocate').hidden = !plane || plane.state !== 'parked' || !!anim;
|
|
$('preview-note').textContent = previewSteps
|
|
? '这是预览。确认才计星。进场仍按已经确认的缺口。'
|
|
: '缺口和刻度差几格,转之前就能数。预览不计星。';
|
|
}
|
|
|
|
function resize() {
|
|
const rect = canvas.getBoundingClientRect();
|
|
view.dpr = Math.min(devicePixelRatio || 1, 2);
|
|
view.w = Math.max(1, rect.width);
|
|
view.h = Math.max(1, rect.height);
|
|
canvas.width = Math.round(view.w * view.dpr);
|
|
canvas.height = Math.round(view.h * view.dpr);
|
|
draw();
|
|
}
|
|
|
|
function worldPoint(event) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
return { x: (event.clientX - rect.left - view.ox) / view.size, y: (event.clientY - rect.top - view.oy) / view.size };
|
|
}
|
|
|
|
function poseOf(plane) {
|
|
if (anim?.planeId === plane.id) return anim.pose;
|
|
if (plane.state === 'waiting') {
|
|
const shaped = game.state.planes.map(item => ({ ...item, kind: 'arrival' }));
|
|
return waitingPose({ ...plane, kind: 'arrival' }, shaped, boardRadius(LEVELS[index].cells));
|
|
}
|
|
const at = position(plane.q, plane.r);
|
|
return { x: at.x, y: at.y, angle: plane.dir * Math.PI / 3 };
|
|
}
|
|
|
|
function line(points, color, width) {
|
|
if (!points || points.length < 2) return;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
for (const point of points.slice(1)) ctx.lineTo(point.x, point.y);
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = width;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.stroke();
|
|
}
|
|
|
|
function circle(x, y, r, fill, stroke) {
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
|
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 0.06; ctx.stroke(); }
|
|
}
|
|
|
|
function label(text, x, y, size, color) {
|
|
ctx.fillStyle = color;
|
|
ctx.font = `700 ${size}px 'Resource Han Rounded','PingFang SC',sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
|
|
function sample(points, t) {
|
|
const lengths = [0];
|
|
for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
|
|
const target = lengths.at(-1) * t, at = lengths.findIndex(length => length >= target);
|
|
const i = Math.max(1, at);
|
|
const span = lengths[i] - lengths[i - 1] || 1;
|
|
const u = (target - lengths[i - 1]) / span;
|
|
const a = points[i - 1], b = points[i];
|
|
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, angle: Math.atan2(b.x - a.x, -(b.y - a.y)) };
|
|
}
|
|
|
|
function draw() {
|
|
const level = LEVELS[index];
|
|
ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
|
|
ctx.clearRect(0, 0, view.w, view.h);
|
|
const radius = boardRadius(level.cells);
|
|
const reach = apronDistance(radius) + 1.15;
|
|
view.size = Math.min(view.w, view.h) / (reach * 2);
|
|
view.ox = view.w / 2;
|
|
view.oy = view.h / 2;
|
|
ctx.translate(view.ox, view.oy);
|
|
ctx.scale(view.size, view.size);
|
|
const hull = trayHull(level.cells);
|
|
let hullR = 0;
|
|
for (const point of hull) hullR = Math.max(hullR, Math.hypot(point.x, point.y));
|
|
const ringR = hullR + 0.55;
|
|
|
|
ctx.beginPath();
|
|
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y + 0.12) : ctx.moveTo(point.x, point.y + 0.12));
|
|
ctx.closePath();
|
|
ctx.fillStyle = '#E6DDD6';
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
|
ctx.closePath();
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.fill();
|
|
|
|
for (const cell of level.cells) {
|
|
const at = position(cell.q, cell.r);
|
|
const pts = hexPoints(at.x, at.y, 0.9);
|
|
ctx.beginPath();
|
|
pts.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
|
ctx.closePath();
|
|
ctx.fillStyle = '#F8E7DC';
|
|
ctx.fill();
|
|
}
|
|
for (const cell of level.cells) {
|
|
const at = position(cell.q, cell.r);
|
|
for (let dir = 0; dir < 6; dir++) {
|
|
const other = cell.tracks[dir];
|
|
if (dir >= other) continue;
|
|
line(trackPoints(at.x, at.y, 0.92, dir, other), '#96707B', 0.09);
|
|
}
|
|
}
|
|
|
|
for (let side = 0; side < 6; side++) {
|
|
const normal = bayNormal(side);
|
|
const distance = apronDistance(radius) + 0.72;
|
|
label(SIDES[side], normal.x * distance, normal.y * distance, 0.32, '#A98590');
|
|
}
|
|
|
|
for (const mouth of mouths) {
|
|
const color = COLORS[mouth.stand.color].color;
|
|
const previewOpen = mouth.tick === preview;
|
|
const committedOpen = mouth.tick === game.state.dial;
|
|
line(mouth.arrivalPoints, previewOpen ? '#F7C948' : 'rgba(150,112,123,.35)', previewOpen ? 0.16 : 0.07);
|
|
const gate = mouth.arrivalPoints[0];
|
|
circle(gate.x, gate.y, committedOpen ? 0.22 : 0.16, previewOpen ? color : '#FFFFFF', color);
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, ringR, 0, Math.PI * 2);
|
|
ctx.strokeStyle = '#E6DDD6';
|
|
ctx.lineWidth = 0.08;
|
|
ctx.stroke();
|
|
for (let seat = 0; seat < 6; seat++) {
|
|
const angle = -Math.PI / 2 + seat * Math.PI / 3;
|
|
circle(Math.cos(angle) * ringR, Math.sin(angle) * ringR, 0.1, '#F5EFE7', '#E6DDD6');
|
|
}
|
|
const groups = new Map();
|
|
for (const mouth of mouths) {
|
|
if (!groups.has(mouth.tick)) groups.set(mouth.tick, []);
|
|
groups.get(mouth.tick).push(mouth);
|
|
}
|
|
for (const [tick, group] of groups) {
|
|
const angle = -Math.PI / 2 + tick * Math.PI / 3;
|
|
group.forEach((mouth, slot) => {
|
|
const shift = (slot - (group.length - 1) / 2) * 0.28;
|
|
const x = Math.cos(angle) * ringR + Math.cos(angle + Math.PI / 2) * shift;
|
|
const y = Math.sin(angle) * ringR + Math.sin(angle + Math.PI / 2) * shift;
|
|
const gate = mouth.arrivalPoints[0];
|
|
line([{ x, y }, gate], COLORS[mouth.stand.color].color, 0.035);
|
|
circle(x, y, 0.28, COLORS[mouth.stand.color].color, '#FFFFFF');
|
|
});
|
|
}
|
|
const notchAngle = -Math.PI / 2 + preview * Math.PI / 3;
|
|
const nx = Math.cos(notchAngle) * (ringR + 0.42), ny = Math.sin(notchAngle) * (ringR + 0.42);
|
|
ctx.save();
|
|
ctx.translate(nx, ny);
|
|
ctx.rotate(notchAngle + Math.PI / 2);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -0.38);
|
|
ctx.lineTo(0.26, 0.22);
|
|
ctx.lineTo(-0.26, 0.22);
|
|
ctx.closePath();
|
|
ctx.fillStyle = previewSteps ? '#E07416' : '#5A3A44';
|
|
ctx.fill();
|
|
ctx.restore();
|
|
if (preview !== game.state.dial) {
|
|
const held = -Math.PI / 2 + game.state.dial * Math.PI / 3;
|
|
circle(Math.cos(held) * ringR, Math.sin(held) * ringR, 0.12, '#5A3A44');
|
|
}
|
|
|
|
for (const stand of level.stands) {
|
|
const at = position(stand.q, stand.r), color = COLORS[stand.color].color;
|
|
const occupied = game.state.planes.some(plane => plane.state === 'parked' && plane.q === stand.q && plane.r === stand.r && anim?.planeId !== plane.id);
|
|
if (occupied) continue;
|
|
ctx.beginPath();
|
|
hexPoints(at.x, at.y, 0.62).forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
|
ctx.closePath();
|
|
ctx.fillStyle = color;
|
|
ctx.fill();
|
|
circle(at.x, at.y, 0.36, '#FFFFFF');
|
|
label(COLORS[stand.color].name[0], at.x, at.y, 0.34, color);
|
|
}
|
|
|
|
if (feedback && performance.now() < feedback.until) {
|
|
line(feedback.travel, '#F0663A', 0.14);
|
|
const tip = feedback.travel.at(-1);
|
|
line([{ x: tip.x - 0.16, y: tip.y - 0.16 }, { x: tip.x + 0.16, y: tip.y + 0.16 }], '#F0663A', 0.08);
|
|
line([{ x: tip.x - 0.16, y: tip.y + 0.16 }, { x: tip.x + 0.16, y: tip.y - 0.16 }], '#F0663A', 0.08);
|
|
}
|
|
|
|
poses = [];
|
|
for (const plane of game.state.planes) {
|
|
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
|
|
const ray = checkRelocate(level, game.state, plane.id);
|
|
if (ray.points) line(ray.points, plane.id === selected ? COLORS[plane.color].color : 'rgba(90,58,68,.35)', plane.id === selected ? 0.1 : 0.05);
|
|
}
|
|
const pose = poseOf(plane);
|
|
poses.push({ id: plane.id, ...pose });
|
|
drawPlane(plane, pose);
|
|
}
|
|
}
|
|
|
|
function drawPlane(plane, pose) {
|
|
const color = COLORS[plane.color].color;
|
|
ctx.save();
|
|
ctx.translate(pose.x, pose.y);
|
|
ctx.rotate(pose.angle || 0);
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -0.62);
|
|
ctx.lineTo(0.16, -0.2);
|
|
ctx.lineTo(0.55, 0.05);
|
|
ctx.lineTo(0.16, 0.12);
|
|
ctx.lineTo(0.12, 0.42);
|
|
ctx.lineTo(0, 0.28);
|
|
ctx.lineTo(-0.12, 0.42);
|
|
ctx.lineTo(-0.16, 0.12);
|
|
ctx.lineTo(-0.55, 0.05);
|
|
ctx.lineTo(-0.16, -0.2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
|
|
circle(pose.x + 0.42, pose.y + 0.36, 0.18, '#E07416');
|
|
label('P', pose.x + 0.42, pose.y + 0.36, 0.22, '#FFFFFF');
|
|
}
|
|
if (plane.id === selected) circle(pose.x, pose.y, 0.78, null, '#5A3A44');
|
|
}
|
|
|
|
function frame(now) {
|
|
if (!anim) return;
|
|
const t = Math.min(1, (now - anim.start) / anim.duration);
|
|
const eased = t * t * (3 - 2 * t);
|
|
anim.pose = sample(anim.points, eased);
|
|
draw();
|
|
if (t < 1) requestAnimationFrame(frame);
|
|
else finishAnim();
|
|
}
|
|
|
|
function finishAnim() {
|
|
anim = null;
|
|
sync();
|
|
draw();
|
|
if (game.won) openResult();
|
|
}
|
|
|
|
function play(points, planeId) {
|
|
anim = { points, planeId, start: performance.now(), duration: 720, pose: points[0] };
|
|
sync();
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
function fail(result, planeId) {
|
|
selected = planeId;
|
|
if (result.travel) feedback = { travel: result.travel, until: performance.now() + 900 };
|
|
$('notice').textContent = result.status === 'blocked' ? '被挡住了。只标第一个阻挡,不计星。' : '这条边现在没有开着的同色口。';
|
|
draw();
|
|
}
|
|
|
|
function onPlane(id) {
|
|
if (anim || game.won) return;
|
|
const plane = game.state.planes.find(item => item.id === id);
|
|
if (plane.state === 'parked') {
|
|
selected = id;
|
|
const ray = checkRelocate(LEVELS[index], game.state, id);
|
|
sync();
|
|
$('notice').textContent = ray.sideName ? `调边会落到${ray.sideName}。这条射线不随转改变。` : LEVELS[index].lesson;
|
|
draw();
|
|
return;
|
|
}
|
|
if (previewSteps) {
|
|
sync();
|
|
$('notice').textContent = '先确认这一转,或转回原位。预览不能进场。';
|
|
return;
|
|
}
|
|
const result = game.act({ type: 'land', id });
|
|
if (result.status !== 'land') { fail(result, id); return; }
|
|
selected = null;
|
|
const from = waitingPose({ ...plane, kind: 'arrival', state: 'waiting' }, [{ ...plane, kind: 'arrival', state: 'waiting' }], boardRadius(LEVELS[index].cells));
|
|
play([from, ...result.mouth.arrivalPoints], id);
|
|
sync();
|
|
}
|
|
|
|
function turn(direction) {
|
|
if (anim) return;
|
|
preview = (preview + direction + 6) % 6;
|
|
previewSteps += direction;
|
|
if (previewSteps === 0) preview = game.state.dial;
|
|
sync();
|
|
draw();
|
|
}
|
|
|
|
function confirm() {
|
|
if (!previewSteps || anim) return;
|
|
game.act({ type: 'confirm', dial: preview, steps: Math.abs(previewSteps) });
|
|
previewSteps = 0;
|
|
preview = game.state.dial;
|
|
sync();
|
|
draw();
|
|
}
|
|
|
|
function relocate() {
|
|
if (anim || !selected) return;
|
|
const before = game.state.planes.find(plane => plane.id === selected);
|
|
const result = game.act({ type: 'relocate', id: selected });
|
|
if (result.status !== 'relocate') { fail(result, selected); return; }
|
|
const end = waitingPose({ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }, [{ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }], boardRadius(LEVELS[index].cells));
|
|
selected = null;
|
|
play([...result.points, end], before.id);
|
|
sync();
|
|
}
|
|
|
|
function openResult() {
|
|
const level = LEVELS[index];
|
|
$('result-copy').textContent = `提交 ${game.state.stars} / 标准 ${level.standard}。${level.lesson}`;
|
|
$('next').hidden = index === LEVELS.length - 1;
|
|
$('result').showModal();
|
|
}
|
|
|
|
$('left').onclick = () => turn(-1);
|
|
$('right').onclick = () => turn(1);
|
|
$('confirm').onclick = confirm;
|
|
$('undo').onclick = () => { game.act({ type: 'undo' }); preview = game.state.dial; previewSteps = 0; selected = null; sync(); draw(); };
|
|
$('restart').onclick = () => load(index);
|
|
$('relocate').onclick = relocate;
|
|
$('levels').onclick = () => $('menu').showModal();
|
|
$('help-open').onclick = () => $('help').showModal();
|
|
$('next').onclick = () => { $('result').close(); load(index + 1); };
|
|
$('retry').onclick = () => { $('result').close(); load(index); };
|
|
document.querySelectorAll('[data-close]').forEach(button => button.onclick = () => button.closest('dialog').close());
|
|
|
|
const list = $('level-list');
|
|
LEVELS.forEach((level, i) => {
|
|
const button = document.createElement('button');
|
|
button.textContent = `${i + 1} ${level.name}`;
|
|
button.onclick = () => { $('menu').close(); load(i); };
|
|
list.append(button);
|
|
});
|
|
|
|
canvas.addEventListener('pointerdown', event => {
|
|
const at = worldPoint(event);
|
|
const hit = poses?.find(pose => Math.hypot(pose.x - at.x, pose.y - at.y) < 0.9);
|
|
if (hit) onPlane(hit.id);
|
|
});
|
|
|
|
new ResizeObserver(resize).observe(canvas);
|
|
load(index);
|