155 lines
5.0 KiB
JavaScript
155 lines
5.0 KiB
JavaScript
// Pure helpers for the map view: geometry, path timing and colour blending.
|
|
// Nothing here touches the DOM or the MapView instance, so it is all trivially
|
|
// testable.
|
|
|
|
import { HEX_W, HEX_H } from "../../../shared/hex.js";
|
|
import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js";
|
|
|
|
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
|
|
// rebuilding a layer when the snapshot did not actually change it.
|
|
export function hashEntries(entries) {
|
|
let hash = 2166136261;
|
|
for (const entry of entries) {
|
|
hash = Math.imul(hash ^ (entry[0] + 4096), 16777619);
|
|
hash = Math.imul(hash ^ (entry[1] + 4096), 16777619);
|
|
hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619);
|
|
}
|
|
return `${entries.length}:${(hash >>> 0).toString(36)}`;
|
|
}
|
|
|
|
// How far along its path a unit is, in segment-hours, from a snapshot.
|
|
export function hoursAlong(data, segmentHours) {
|
|
const index = Math.min(data.pathIndex || 0, segmentHours.length);
|
|
let total = 0;
|
|
for (let i = 0; i < index; i++) total += segmentHours[i];
|
|
return total + (data.progressHours || 0);
|
|
}
|
|
|
|
export function samePath(a, b) {
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The segment index a motion is currently on and how far into it it is.
|
|
export function pathProgress(motion) {
|
|
let remaining = motion.pathHours;
|
|
let index = 0;
|
|
while (index < motion.segmentHours.length) {
|
|
const step = motion.segmentHours[index];
|
|
if (step <= 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (remaining < step) break;
|
|
remaining -= step;
|
|
index += 1;
|
|
}
|
|
return { index, remaining };
|
|
}
|
|
|
|
export function nearestCorners(center, target) {
|
|
const half = { x: HEX_W / 2, y: HEX_H / 2 };
|
|
const corners = [
|
|
{ x: half.x, y: 0 },
|
|
{ x: half.x * 0.5, y: -half.y },
|
|
{ x: -half.x * 0.5, y: -half.y },
|
|
{ x: -half.x, y: 0 },
|
|
{ x: -half.x * 0.5, y: half.y },
|
|
{ x: half.x * 0.5, y: half.y },
|
|
];
|
|
let first = 0;
|
|
let second = 1;
|
|
let firstDistance = Infinity;
|
|
let secondDistance = Infinity;
|
|
for (let i = 0; i < corners.length; i++) {
|
|
const point = { x: center.x + corners[i].x, y: center.y + corners[i].y };
|
|
const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2;
|
|
if (distance < firstDistance) {
|
|
secondDistance = firstDistance;
|
|
second = first;
|
|
firstDistance = distance;
|
|
first = i;
|
|
} else if (distance < secondDistance) {
|
|
secondDistance = distance;
|
|
second = i;
|
|
}
|
|
}
|
|
return [
|
|
{ x: center.x + corners[first].x, y: center.y + corners[first].y },
|
|
{ x: center.x + corners[second].x, y: center.y + corners[second].y },
|
|
];
|
|
}
|
|
|
|
// Signed distance to nudge an edge segment along its normal so it moves towards
|
|
// `center` (the owning tile). Used to keep neighbours' borders side by side
|
|
// instead of overlapping on the shared edge.
|
|
export function inwardOffset(a, b, center, distance) {
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
const nx = -dy / length;
|
|
const ny = dx / length;
|
|
const midX = (a.x + b.x) / 2;
|
|
const midY = (a.y + b.y) / 2;
|
|
const dot = (center.x - midX) * nx + (center.y - midY) * ny;
|
|
return dot >= 0 ? distance : -distance;
|
|
}
|
|
|
|
export function makeSegment(a, b, colour, width, offset = 0) {
|
|
const div = document.createElement("div");
|
|
div.className = "border-seg";
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy);
|
|
const angle = Math.atan2(dy, dx);
|
|
div.style.left = `${a.x}px`;
|
|
div.style.top = `${a.y - width / 2}px`;
|
|
div.style.width = `${length}px`;
|
|
div.style.height = `${width}px`;
|
|
div.style.background = colour;
|
|
div.style.transform = offset === 0
|
|
? `rotate(${angle}rad)`
|
|
: `rotate(${angle}rad) translateY(${offset}px)`;
|
|
div.style.borderRadius = `${width / 2}px`;
|
|
return div;
|
|
}
|
|
|
|
export function overviewColour(tile) {
|
|
if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) {
|
|
return OVERVIEW_COLOURS[tile.terrainType];
|
|
}
|
|
return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land;
|
|
}
|
|
|
|
// Mixes two #rrggbb colours, `t` being how much of `b` to use.
|
|
export function blendColours(a, b, t) {
|
|
const ca = parseColour(a);
|
|
const cb = parseColour(b);
|
|
const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t);
|
|
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
|
|
}
|
|
|
|
export function parseColour(value) {
|
|
if (value.charAt(0) === "#") {
|
|
const hex = value.slice(1);
|
|
return [
|
|
parseInt(hex.slice(0, 2), 16),
|
|
parseInt(hex.slice(2, 4), 16),
|
|
parseInt(hex.slice(4, 6), 16),
|
|
];
|
|
}
|
|
// Tolerates the "rgb(r, g, b)" form blendColours produces, so a blended
|
|
// overview colour can be fed straight back in.
|
|
const match = value.match(/(\d+)\D+(\d+)\D+(\d+)/);
|
|
if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
return [0, 0, 0];
|
|
}
|
|
|
|
export function clamp(value, min, max) {
|
|
if (min > max) return (min + max) / 2;
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|