200 lines
11 KiB
TypeScript
200 lines
11 KiB
TypeScript
import type {Bot, RaycastBlock} from '@wp2508/mineflayer';
|
|
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
|
|
import {on_function_cancel} from '../cancellation.ts';
|
|
|
|
type Block = NonNullable<ReturnType<Bot['blockAt']>>;
|
|
type Vector = Bot['entity']['position'];
|
|
export type BlockOperation = 'destroy' | 'place' | 'use';
|
|
export type BlockTarget = 'cursor' | 'nearest' | 'position' | 'group';
|
|
export type CursorArgs = [reach: number, need_look?: boolean];
|
|
export type NearestArgs = [name: string, reach: number, face: number, need_look?: boolean];
|
|
export type PositionArgs = [x: number, y: number, z: number, face: number, need_look?: boolean];
|
|
export type GroupArgs = [positions: number[], need_look?: boolean];
|
|
|
|
const digging = new WeakSet<Bot>();
|
|
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]] as const;
|
|
|
|
export function block_arguments(target: BlockTarget): FunctionArgument[] {
|
|
const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
|
|
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
|
|
const reach = arg('reach', 'number', '最大距离', '眼睛到方块碰撞箱的最大距离,必须大于 0 且不超过 6');
|
|
const face = arg('face', 'number', '操作面', '整数 0 下、1 上、2 北、3 南、4 西、5 东');
|
|
const look = {...arg('need_look', 'boolean', '转向操作面', '操作前转向方块操作面的中心,默认不转向'), default: false};
|
|
switch (target) {
|
|
case 'cursor': return [reach, look];
|
|
case 'nearest': return [arg('name', 'string', '方块 ID', '不区分大小写的方块 ID 子串,可带 minecraft: 前缀'), reach, face, look];
|
|
case 'position': return [
|
|
...['x', 'y', 'z'].map(axis => arg(axis, 'number', `${axis.toUpperCase()} 坐标`, '方块坐标,必须是安全整数;最大操作距离为 4.5')),
|
|
face, look,
|
|
];
|
|
case 'group': return [arg('positions', 'number-array', '坐标与操作面列表', '非空平铺数组,每四项为 x、y、z、face;坐标为安全整数,面为 0 到 5,最大距离 4.5;按顺序操作,失败停止且不回滚'), look];
|
|
}
|
|
}
|
|
|
|
function valid_face(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 5;
|
|
}
|
|
|
|
function valid_position(values: unknown[]): boolean {
|
|
return values.length === 4 && [values[0], values[1], values[2]].every(value => typeof value === 'number' && Number.isSafeInteger(value))
|
|
&& valid_face(values[3]);
|
|
}
|
|
|
|
function valid_block(block: Block | null | undefined): block is Block {
|
|
return !!block && !!block.position && typeof block.name === 'string'
|
|
&& !['air', 'cave_air', 'void_air'].includes(block.name.toLowerCase().replace(/^minecraft:/, ''))
|
|
&& [block.position.x, block.position.y, block.position.z].every(Number.isSafeInteger);
|
|
}
|
|
|
|
function eye(bot: Bot): Vector | undefined {
|
|
const self = bot.entity;
|
|
const height = (self as Bot['entity'] & {eyeHeight?: number})?.eyeHeight ?? self?.height;
|
|
if (!self?.position || ![self.position.x, self.position.y, self.position.z, height].every(Number.isFinite)) return;
|
|
return self.position.offset(0, height, 0);
|
|
}
|
|
|
|
function distance(point: Vector, block: Block): number {
|
|
const pos = block.position;
|
|
return Math.hypot(Math.max(pos.x - point.x, point.x - pos.x - 1, 0),
|
|
Math.max(pos.y - point.y, point.y - pos.y - 1, 0), Math.max(pos.z - point.z, point.z - pos.z - 1, 0));
|
|
}
|
|
|
|
export async function run_block_operation(bot: Bot, args: unknown[], target: BlockTarget, operation: BlockOperation): Promise<boolean> {
|
|
if (!Array.isArray(args)) return false;
|
|
const length = target === 'position' ? 4 : target === 'nearest' ? 3 : 1;
|
|
if (args.length !== length && args.length !== length + 1) return false;
|
|
if (args.length > length && typeof args[length] !== 'boolean') return false;
|
|
const need_look = args[length] === true;
|
|
args = args.slice(0, length);
|
|
let positions: number[][] = [];
|
|
let reach = 4.5;
|
|
let query = '';
|
|
if (target === 'cursor' || target === 'nearest') {
|
|
if (args.length !== (target === 'cursor' ? 1 : 3)) return false;
|
|
const value = args[target === 'cursor' ? 0 : 1];
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 6) return false;
|
|
reach = value;
|
|
if (target === 'nearest') {
|
|
if (typeof args[0] !== 'string' || !valid_face(args[2])) return false;
|
|
query = args[0].trim().toLowerCase().replace(/^minecraft:/, '');
|
|
if (!query) return false;
|
|
}
|
|
} else if (target === 'position') {
|
|
if (!valid_position(args)) return false;
|
|
positions = [args.slice() as number[]];
|
|
} else {
|
|
if (args.length !== 1 || !Array.isArray(args[0]) || !args[0].length || args[0].length % 4 !== 0) return false;
|
|
for (let i = 0; i < args[0].length; i += 4) {
|
|
const position = args[0].slice(i, i + 4);
|
|
if (!valid_position(position)) return false;
|
|
positions.push(position);
|
|
}
|
|
}
|
|
|
|
const self = bot.entity;
|
|
const client = bot._client;
|
|
let ended = false;
|
|
let owned_dig: Block | undefined;
|
|
let cancel!: () => void;
|
|
const cancelled = new Promise<false>(resolve => { cancel = () => resolve(false); });
|
|
const active = () => !ended && !!self && !!client && bot.entity === self && bot._client === client
|
|
&& self.isValid !== false && !(client as typeof client & {ended?: boolean}).ended;
|
|
const stop_owned = () => {
|
|
if (owned_dig && bot.targetDigBlock === owned_dig) {
|
|
try { bot.stopDigging(); } catch { /* The connection may already be closed. */ }
|
|
}
|
|
};
|
|
const on_end = () => { ended = true; stop_owned(); cancel(); };
|
|
const unwatch = on_function_cancel(bot, on_end);
|
|
const wait = async (promise: Promise<unknown>): Promise<boolean> => {
|
|
const result = await Promise.race([promise.then(() => true, () => false), cancelled]);
|
|
return result && active();
|
|
};
|
|
try {
|
|
bot.on('end', on_end);
|
|
if (!active() || !eye(bot) || operation === 'place' && !bot.heldItem) return false;
|
|
let selected: Block | undefined;
|
|
let cursor: RaycastBlock | undefined;
|
|
if (target === 'cursor') {
|
|
cursor = bot.blockAtCursor(reach) ?? undefined;
|
|
if (!valid_block(cursor) || !valid_face(cursor.face) || !cursor.intersect
|
|
|| ![cursor.intersect.x, cursor.intersect.y, cursor.intersect.z].every(Number.isFinite)) return false;
|
|
selected = cursor;
|
|
} else if (target === 'nearest') {
|
|
// findBlocks sorts block origins around a floored point. Include every
|
|
// candidate before applying our eye-to-AABB distance ordering.
|
|
const radius = Math.ceil(reach + 2 * Math.sqrt(3));
|
|
const matching = (block: Block | null) => !!block && typeof block.name === 'string'
|
|
&& block.name.toLowerCase().replace(/^minecraft:/, '').includes(query);
|
|
selected = bot.findBlocks({point: eye(bot)!, matching, maxDistance: radius, count: (2 * radius + 1) ** 3})
|
|
.map(position => bot.blockAt(position)).filter(valid_block).filter(matching)
|
|
.map(block => ({block: block!, distance: distance(eye(bot)!, block!)}))
|
|
.filter(entry => entry.distance <= reach).sort((a, b) => a.distance - b.distance)[0]?.block;
|
|
if (!selected) return false;
|
|
}
|
|
const total = target === 'group' ? positions.length : 1;
|
|
for (let i = 0; i < total; i++) {
|
|
if (!active()) return false;
|
|
const position = positions[i];
|
|
const block = selected ?? bot.blockAt(self.position.scaled(0).offset(position![0]!, position![1]!, position![2]!));
|
|
const point = eye(bot);
|
|
if (!valid_block(block) || !point || distance(point, block) > reach) return false;
|
|
const loaded = bot.blockAt(block.position);
|
|
if (!valid_block(loaded) || loaded.type !== block.type) return false;
|
|
const face = cursor?.face ?? (target === 'nearest' ? args[2] as number : position![3]!);
|
|
const components = faces[face]!;
|
|
const vector = self.position.scaled(0).offset(components[0], components[1], components[2]);
|
|
const delta = cursor?.intersect.minus(block.position);
|
|
const {type, stateId} = block;
|
|
const valid = () => {
|
|
const point = eye(bot);
|
|
const loaded = bot.blockAt(block.position);
|
|
return active() && !!point && distance(point, block) <= reach && valid_block(loaded)
|
|
&& loaded.type === type && loaded.stateId === stateId
|
|
&& (operation !== 'place' || !!bot.heldItem)
|
|
&& (operation !== 'destroy' || bot.canDigBlock(block));
|
|
};
|
|
if (operation === 'destroy') {
|
|
if (digging.has(bot) || bot.targetDigBlock || !bot.canDigBlock(block)) return false;
|
|
digging.add(bot);
|
|
owned_dig = block;
|
|
}
|
|
try {
|
|
if (need_look && !await wait(bot.lookAt(block.position.offset(0.5, 0.5, 0.5).plus(vector.scaled(0.5))))) return false;
|
|
if (!valid()) return false;
|
|
if (operation === 'destroy') {
|
|
if (bot.targetDigBlock) return false;
|
|
try {
|
|
const promise = bot.dig(block, 'ignore', vector);
|
|
if (!await wait(promise)) { stop_owned(); return false; }
|
|
} catch {
|
|
stop_owned();
|
|
return false;
|
|
}
|
|
} else if (operation === 'place') {
|
|
if (!await wait(bot._placeBlockWithOptions(block, vector, {swingArm: 'right', forceLook: 'ignore', delta}))) return false;
|
|
} else if (!await wait(bot.activateBlock(block, vector, delta, 'ignore'))) return false;
|
|
} finally {
|
|
if (operation === 'destroy') {
|
|
owned_dig = undefined;
|
|
digging.delete(bot);
|
|
}
|
|
}
|
|
if (!active()) return false;
|
|
if (operation === 'place' && target === 'group' && i + 1 < total) {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
if (!await wait(new Promise<void>(resolve => { timer = setTimeout(resolve, 100); }))) return false;
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
}
|
|
return active();
|
|
} catch {
|
|
stop_owned();
|
|
return false;
|
|
} finally {
|
|
unwatch();
|
|
bot.removeListener('end', on_end);
|
|
}
|
|
}
|