362 lines
9.9 KiB
JavaScript
362 lines
9.9 KiB
JavaScript
/**
|
|
* D* Lite Pathfinding Algorithm
|
|
*
|
|
* An incremental heuristic search algorithm that efficiently replans
|
|
* when the environment changes (new blocks discovered, blocks mined, etc.)
|
|
*
|
|
* Based on Koenig & Likhachev (2002) and adapted from
|
|
* runi95/turtle-control-panel implementation.
|
|
*/
|
|
import { Point } from './Point.js';
|
|
import { Node } from './Node.js';
|
|
import { PriorityQueue } from './PriorityQueue.js';
|
|
|
|
// Unbreakable blocks that cannot be mined
|
|
const UNBREAKABLE_BLOCKS = new Set([
|
|
'minecraft:bedrock',
|
|
'minecraft:barrier',
|
|
'minecraft:command_block',
|
|
'minecraft:chain_command_block',
|
|
'minecraft:repeating_command_block',
|
|
'minecraft:structure_block',
|
|
'minecraft:jigsaw',
|
|
'minecraft:end_portal_frame',
|
|
'minecraft:end_portal',
|
|
'minecraft:nether_portal',
|
|
'minecraft:spawner',
|
|
'minecraft:reinforced_deepslate',
|
|
]);
|
|
|
|
// Blocks that are liquid/hazardous - avoid unless necessary
|
|
const HAZARDOUS_BLOCKS = new Set([
|
|
'minecraft:lava',
|
|
'minecraft:water',
|
|
'minecraft:flowing_lava',
|
|
'minecraft:flowing_water',
|
|
]);
|
|
|
|
export class DStarLite {
|
|
/**
|
|
* @param {Point} start - Starting position
|
|
* @param {Point} goal - Goal position
|
|
* @param {Map} worldBlocks - Map of "x,y,z" -> blockData from server
|
|
* @param {Object} options - Configuration options
|
|
*/
|
|
constructor(start, goal, worldBlocks, options = {}) {
|
|
this.start = start;
|
|
this.goal = goal;
|
|
this.worldBlocks = worldBlocks;
|
|
this.km = 0;
|
|
this.nodes = new Map(); // key -> Node
|
|
this.queue = new PriorityQueue();
|
|
|
|
// Options
|
|
this.maxSteps = options.maxSteps || 50000;
|
|
this.canMine = options.canMine !== false; // Default: true - can mine through blocks
|
|
this.boundaryMin = options.boundaryMin || null; // Point - min boundary
|
|
this.boundaryMax = options.boundaryMax || null; // Point - max boundary
|
|
this.avoidHazards = options.avoidHazards !== false; // Default: true
|
|
|
|
// Initialize
|
|
this._initialize();
|
|
}
|
|
|
|
/**
|
|
* Get or create a node for a point
|
|
*/
|
|
getNode(point) {
|
|
const key = point.toKey();
|
|
if (!this.nodes.has(key)) {
|
|
const node = new Node(point);
|
|
|
|
// Check world blocks for this position
|
|
const blockData = this.worldBlocks.get(key);
|
|
if (blockData) {
|
|
node.blockData = blockData;
|
|
|
|
if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
|
node.blocked = true;
|
|
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
|
node.blocked = true;
|
|
} else if (blockData.name && blockData.name !== 'minecraft:air') {
|
|
// There's a block here - it's mineable
|
|
node.mineable = true;
|
|
}
|
|
}
|
|
|
|
// Check boundary constraints
|
|
if (this.boundaryMin && this.boundaryMax) {
|
|
if (point.x < this.boundaryMin.x || point.x > this.boundaryMax.x ||
|
|
point.y < this.boundaryMin.y || point.y > this.boundaryMax.y ||
|
|
point.z < this.boundaryMin.z || point.z > this.boundaryMax.z) {
|
|
node.blocked = true;
|
|
}
|
|
}
|
|
|
|
// Don't go below bedrock
|
|
if (point.y < -64) node.blocked = true;
|
|
if (point.y > 320) node.blocked = true;
|
|
|
|
this.nodes.set(key, node);
|
|
}
|
|
return this.nodes.get(key);
|
|
}
|
|
|
|
/**
|
|
* Initialize the D* Lite algorithm
|
|
*/
|
|
_initialize() {
|
|
const goalNode = this.getNode(this.goal);
|
|
goalNode.rhs = 0;
|
|
|
|
const key = goalNode.calculateKey(this.start, this.km);
|
|
this.queue.insertOrUpdate(goalNode, key);
|
|
}
|
|
|
|
/**
|
|
* Compute the shortest path
|
|
* Returns true if a path exists, false otherwise
|
|
*/
|
|
computeShortestPath() {
|
|
const startNode = this.getNode(this.start);
|
|
let steps = 0;
|
|
|
|
while (
|
|
!this.queue.isEmpty() &&
|
|
(PriorityQueue.compareKeys(this.queue.topKey(), startNode.calculateKey(this.start, this.km)) < 0 ||
|
|
startNode.rhs !== startNode.g)
|
|
) {
|
|
steps++;
|
|
if (steps > this.maxSteps) {
|
|
console.warn(`D* Lite: Max steps (${this.maxSteps}) exceeded`);
|
|
return false;
|
|
}
|
|
|
|
const oldKey = this.queue.topKey();
|
|
const u = this.queue.pop();
|
|
if (!u) break;
|
|
|
|
const newKey = u.calculateKey(this.start, this.km);
|
|
|
|
if (PriorityQueue.compareKeys(oldKey, newKey) < 0) {
|
|
// Key has changed, reinsert with new key
|
|
this.queue.insertOrUpdate(u, newKey);
|
|
} else if (u.g > u.rhs) {
|
|
// Overconsistent - decrease g
|
|
u.g = u.rhs;
|
|
|
|
// Update predecessors
|
|
const neighbors = u.point.getNeighbors();
|
|
for (const neighborPoint of neighbors) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
this._updateNode(neighborNode);
|
|
}
|
|
} else {
|
|
// Underconsistent - increase g
|
|
u.g = Infinity;
|
|
|
|
// Update u and its predecessors
|
|
this._updateNode(u);
|
|
const neighbors = u.point.getNeighbors();
|
|
for (const neighborPoint of neighbors) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
this._updateNode(neighborNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
return startNode.g !== Infinity;
|
|
}
|
|
|
|
/**
|
|
* Update a node's rhs value and queue status
|
|
*/
|
|
_updateNode(node) {
|
|
if (!node.point.equals(this.goal)) {
|
|
// rhs = min over all successors of (cost(node, succ) + succ.g)
|
|
let minRhs = Infinity;
|
|
const neighbors = node.point.getNeighbors();
|
|
|
|
for (const neighborPoint of neighbors) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
const cost = this._cost(node, neighborNode);
|
|
const val = cost + neighborNode.g;
|
|
if (val < minRhs) {
|
|
minRhs = val;
|
|
}
|
|
}
|
|
|
|
node.rhs = minRhs;
|
|
}
|
|
|
|
// Remove from queue if present
|
|
if (this.queue.contains(node)) {
|
|
this.queue.remove(node);
|
|
}
|
|
|
|
// Re-insert if inconsistent
|
|
if (node.g !== node.rhs) {
|
|
const key = node.calculateKey(this.start, this.km);
|
|
this.queue.insertOrUpdate(node, key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the cost to traverse from one node to an adjacent node
|
|
*/
|
|
_cost(from, to) {
|
|
return to.getTraversalCost();
|
|
}
|
|
|
|
/**
|
|
* Get the computed path from start to goal
|
|
* Returns array of Points, or null if no path exists
|
|
*/
|
|
getPath() {
|
|
if (!this.computeShortestPath()) {
|
|
return null;
|
|
}
|
|
|
|
const path = [this.start];
|
|
let current = this.start;
|
|
let maxPathLength = 10000;
|
|
|
|
while (!current.equals(this.goal) && maxPathLength > 0) {
|
|
maxPathLength--;
|
|
|
|
const currentNode = this.getNode(current);
|
|
if (currentNode.g === Infinity) {
|
|
return null; // No path
|
|
}
|
|
|
|
// Find the best neighbor to move to
|
|
let bestNeighbor = null;
|
|
let bestCost = Infinity;
|
|
const neighbors = current.getNeighbors();
|
|
|
|
for (const neighborPoint of neighbors) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
const cost = this._cost(currentNode, neighborNode) + neighborNode.g;
|
|
if (cost < bestCost) {
|
|
bestCost = cost;
|
|
bestNeighbor = neighborPoint;
|
|
}
|
|
}
|
|
|
|
if (!bestNeighbor || bestCost === Infinity) {
|
|
return null; // No path
|
|
}
|
|
|
|
path.push(bestNeighbor);
|
|
current = bestNeighbor;
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Update the algorithm when the turtle moves to a new position
|
|
* This is the key D* Lite optimization - it adjusts km to avoid full recomputation
|
|
*/
|
|
updateStart(newStart) {
|
|
this.km += this.start.euclideanDistanceTo(newStart);
|
|
this.start = newStart;
|
|
}
|
|
|
|
/**
|
|
* Notify the algorithm that a block has changed at a position
|
|
* This triggers efficient replanning
|
|
* @param {Point} point - The position that changed
|
|
* @param {Object|null} blockData - New block data, or null if block was removed
|
|
*/
|
|
updateBlock(point, blockData) {
|
|
const node = this.getNode(point);
|
|
const oldBlocked = node.blocked;
|
|
const oldMineable = node.mineable;
|
|
|
|
// Update node state
|
|
node.blockData = blockData;
|
|
|
|
if (!blockData || blockData.name === 'minecraft:air') {
|
|
node.blocked = false;
|
|
node.mineable = false;
|
|
} else if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
|
node.blocked = true;
|
|
node.mineable = false;
|
|
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
|
node.blocked = true;
|
|
node.mineable = false;
|
|
} else {
|
|
node.blocked = false;
|
|
node.mineable = true;
|
|
}
|
|
|
|
// Only replan if the traversability changed
|
|
if (node.blocked !== oldBlocked || node.mineable !== oldMineable) {
|
|
// Update this node and all its neighbors
|
|
this._updateNode(node);
|
|
const neighbors = point.getNeighbors();
|
|
for (const neighborPoint of neighbors) {
|
|
if (this.nodes.has(neighborPoint.toKey())) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
this._updateNode(neighborNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the next step the turtle should take from current position
|
|
* Returns the next Point to move to, or null if at goal or no path
|
|
*/
|
|
getNextStep() {
|
|
if (this.start.equals(this.goal)) {
|
|
return null; // Already at goal
|
|
}
|
|
|
|
if (!this.computeShortestPath()) {
|
|
return null; // No path
|
|
}
|
|
|
|
const startNode = this.getNode(this.start);
|
|
if (startNode.g === Infinity) {
|
|
return null;
|
|
}
|
|
|
|
let bestNeighbor = null;
|
|
let bestCost = Infinity;
|
|
const neighbors = this.start.getNeighbors();
|
|
|
|
for (const neighborPoint of neighbors) {
|
|
const neighborNode = this.getNode(neighborPoint);
|
|
const cost = this._cost(startNode, neighborNode) + neighborNode.g;
|
|
if (cost < bestCost) {
|
|
bestCost = cost;
|
|
bestNeighbor = neighborPoint;
|
|
}
|
|
}
|
|
|
|
return bestNeighbor;
|
|
}
|
|
|
|
/**
|
|
* Replan the path (called after block updates)
|
|
*/
|
|
replan() {
|
|
return this.computeShortestPath();
|
|
}
|
|
|
|
/**
|
|
* Get statistics about the pathfinding
|
|
*/
|
|
getStats() {
|
|
return {
|
|
nodesExplored: this.nodes.size,
|
|
queueSize: this.queue.size,
|
|
startG: this.getNode(this.start).g,
|
|
goalRhs: this.getNode(this.goal).rhs,
|
|
km: this.km,
|
|
};
|
|
}
|
|
}
|