diff --git a/.gitignore b/.gitignore index 0c80212..b4311c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ # exclude files -debug-stick*.mcpack -debug-stick*.zip - +dist/ +*.mcpack node_modules - -pack/scripts/* diff --git a/pack/README b/BP/README similarity index 100% rename from pack/README rename to BP/README diff --git a/pack/items/debug_stick.json b/BP/items/debug_stick.json similarity index 100% rename from pack/items/debug_stick.json rename to BP/items/debug_stick.json diff --git a/pack/pack_icon.png b/BP/pack_icon.png similarity index 100% rename from pack/pack_icon.png rename to BP/pack_icon.png diff --git a/build/gen_config.js b/build/gen_config.js new file mode 100644 index 0000000..08ff9b7 --- /dev/null +++ b/build/gen_config.js @@ -0,0 +1,32 @@ +import config from '../config.js'; +import { execSync } from 'child_process'; + + +const run = (cmd) => execSync(cmd).toString().trim(); + +const currCommit = run('git rev-parse HEAD'); +const currBranch = run('git branch --show-current'); + +/** + * Stuff that will be used at runtime. + */ +export const runtimeConfigTable = { + version: config.packVersion, + minMcVer: config.minMcVersion, + apiVer: config.dependencies['@minecraft/server'], + uiApiVer: config.dependencies['@minecraft/server-ui'], + branch: currBranch, + commit: currCommit, + shCommit: currCommit.slice(0, 7), +}; + + +/** + * Make a config script for the runtime config. + * @param cfg The generated runtime config object. + * @returns The config script. + */ +export function makeConfigScript(cfg) { + const jsonStr = JSON.stringify(cfg, null, 2); + return `export default ${jsonStr};`; +} diff --git a/build/gen_manifest.js b/build/gen_manifest.js new file mode 100644 index 0000000..af32f7c --- /dev/null +++ b/build/gen_manifest.js @@ -0,0 +1,66 @@ +import config from '../config.js'; +import { runtimeConfigTable as subTab } from './gen_config.js'; +import { formatString } from './utils.js'; + + +/** + * These stay constant. + */ +const PACK_UUID = '21aadfa6-e27c-400c-c596-596021852939'; +const MODULE_DATA_UUID = 'd8a9ff21-7aa3-4b83-73ed-eeb141516e74'; +const MODULE_SCRIPT_UUID = '86c7bab4-aed9-4297-5f0c-d5d62bd30be1'; + + +/** + * Generate the manifest file from a template. + * @returns The generated manifest object. + */ +export function genManifest() { + return { + format_version: 3, + + header: { + name: formatString(config.packName, subTab), + description: formatString(config.packDescription, subTab), + version: config.packVersion, + min_engine_version: config.minMcVersion, + uuid: PACK_UUID, + }, + + modules: [ + { + description: 'behaviour', + type: 'data', + version: '1.0.0', + uuid: MODULE_DATA_UUID, + }, + { + description: 'scripting', + type: 'script', + language: 'javascript', + version: '1.0.0', + uuid: MODULE_SCRIPT_UUID, + entry: config.scriptEntry, + } + ], + + dependencies: Object.entries(config.dependencies) + .map(([k, v]) => ({ module_name: k, version: v })), + + metadata: { + authors: [ 'VYT' ], + license: 'MIT', + url: 'https://github.com/vytdev/debug-stick', + }, + } +} + + +/** + * Pretty JSON-stringify the given manifest object. + * @param manifest The manifest object. + * @returns String. + */ +export function stringifyManifest(manifest) { + return JSON.stringify(manifest, null, 2); +} diff --git a/build/main.js b/build/main.js new file mode 100644 index 0000000..fcc88d4 --- /dev/null +++ b/build/main.js @@ -0,0 +1,41 @@ +import { isAsyncFunction } from 'util/types'; +import { cleanUp, compileSource, createDist, watchSource } from './tasks.js'; + + +const actionTable = {}; + + +/* --- HELP --- */ +actionTable['help'] = () => console.error( + `usage: ${process.argv[1]} [task...]\n` + + 'Utility script for working with the debug-stick project.\n' + + 'Available tasks:\n' + + ' help Shows this help\n' + + ' pack Create dist package\n' + + ' build Run: npx tsc --build\n' + + ' watch Run: npx tsc --watch\n' + + ' clean Remove generated files\n' + + '@vytdev' +); + + +actionTable['pack'] = createDist; +actionTable['build'] = compileSource; +actionTable['watch'] = watchSource; +actionTable['clean'] = cleanUp; + + +// Run each task in given order. +for (const task of process.argv.slice(2)) { + const fn = actionTable[task]; + if (typeof fn !== 'function') { + console.error('task does not exist: ' + task); + continue; + } + + // run the task synchronously + console.log(`--- ${task} ---`); + let code = isAsyncFunction(fn) ? await fn(task) : fn(task); + code = (code || 0) & 0xff; + if (code != 0) process.exit(code); +} diff --git a/build/tasks.js b/build/tasks.js new file mode 100644 index 0000000..79cabcb --- /dev/null +++ b/build/tasks.js @@ -0,0 +1,116 @@ +import JSZip from 'jszip'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; + +import config from '../config.js'; +import { addDirToZip, formatString, runProcessAsync, writeZip } from './utils.js'; +import { runtimeConfigTable, makeConfigScript } from './gen_config.js'; +import { genManifest, stringifyManifest } from './gen_manifest.js'; + + +/** + * Compile source code. + */ +export async function compileSource() { + console.log('compiling source ...'); + const err = await runProcessAsync('npx', 'tsc', '--build'); + console.log('typescript exited with code: ' + err); +} + + +/** + * Start live incremental compilation. + */ +export async function watchSource() { + console.log('watching src/ ...'); + const err = await runProcessAsync('npx', 'tsc', '--watch'); + console.log('stopped watching src/ !'); + console.log('typescript exited with code: ' + err); +} + + +/** + * Add LICENSE file. + * @param zip + */ +export async function addLicense(zip) { + console.log('including LICENSE'); + const str = fs.createReadStream('LICENSE'); + zip.file('LICENSE', str) +} + + +/** + * Add scrips/config.js. + * @param zip + */ +export async function addConfig(zip) { + console.log('generating scripts/config.js'); + const str = makeConfigScript(runtimeConfigTable); + zip.file('scripts/config.js', str); +} + + +/** + * Add manifest.json. + * @param zip + */ +export async function addManifest(zip) { + console.log('generating manifest.json'); + const str = stringifyManifest(genManifest()); + zip.file('manifest.json', str); +} + + +/** + * Add files from BP/ folder. + * @param zip + */ +export async function addBP(zip) { + return addDirToZip(zip, config.staticSrc); +} + + +/** + * Add files from dist/js-out/ to scripts/. + * @param zip + */ +export async function addJSOut(zip) { + const jsOut = path.join(config.distDir, 'js-out'); + return addDirToZip(zip.folder('scripts'), jsOut); +} + + +/** + * Create dist package. + */ +export async function createDist() { + const zip = new JSZip(); + await Promise.all([ + addLicense(zip), + addConfig(zip), + addManifest(zip), + addBP(zip), + addJSOut(zip), + ]); + + if (!fs.statSync(config.distDir)?.isDirectory()) + await fsp.mkdir(config.distDir); + + const outFilePath = path.join(config.distDir, + formatString(config.outFileFmt, runtimeConfigTable)); + + await writeZip(zip, outFilePath); +} + + +/** + * Clean-up generated files. Note: does not include the dist files. + */ +export async function cleanUp() { + await fsp.rm(path.join(config.distDir, 'js-out'), { + recursive: true, + force: true, + }); +} diff --git a/build/utils.js b/build/utils.js new file mode 100644 index 0000000..8bf570b --- /dev/null +++ b/build/utils.js @@ -0,0 +1,85 @@ +import child_process from 'child_process'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; + + +/** + * Format string with '{key}' substitution. + * @param template The template, with optional '{key}'. + * @param subTab The substitution table. + * @returns The string. + */ +export function formatString(template, subTab) { + return template.replace(/\{([\w$_]+)\}/g, (old, arg) => + (subTab[arg] ?? old)); +} + + +/** + * Asynchronously run a sub-process. + * @param arg0 Name of or path to the executable. + * @param args Arguments to pass to the process. + * @returns A Promise. + */ +export async function runProcessAsync(arg0, ...args) { + return new Promise((resolve, reject) => { + const subproc = child_process.spawn(arg0, args, { stdio: 'inherit' }); + // redirect sigint temporarily + const sigIntHandler = () => subproc.kill('SIGINT'); + process.on('SIGINT', sigIntHandler); + // handle success and failure + subproc.on('close', code => { + process.off('SIGINT', sigIntHandler); + resolve(code || 0); + }); + subproc.on('error', err => { + process.off('SIGINT', sigIntHandler); + reject(err) + }); + }); +} + + +/** + * Adds a directory into the given zip object. + * @param zipObj The zip object. + * @param folder The folder to zip. + */ +export async function addDirToZip(zipObj, folder) { + const items = await fsp.readdir(folder); + + const addItem = async (item) => { + const fullPath = path.join(folder, item); + console.log('adding ' + fullPath); // feedback + const stat = await fsp.stat(fullPath); + if (stat.isDirectory()) { + const subFolder = zipObj.folder(item); + await addDirToZip(subFolder, fullPath); + } else { + zipObj.file(item, fs.createReadStream(fullPath)); + } + }; + + await Promise.all(items.map(addItem)); +} + + +/** + * Write the zip file. + * @param zipObj The zip object. + * @param outPath The output path. + */ +export async function writeZip(zipObj, outPath) { + const output = fs.createWriteStream(outPath); + + zipObj.generateNodeStream({ + type: 'nodebuffer', + streamFiles: true, + }).pipe(output); + + return new Promise((resolve, reject) => { + output.on('finish', resolve); + output.on('error', reject); + }); +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..d75f31a --- /dev/null +++ b/config.js @@ -0,0 +1,62 @@ +/** + * Configs. + */ + +export default { + /** + * The folder containing static files needed by the add-on. + */ + staticSrc: 'BP', + + /** + * Where to place output .mcpack files. + */ + distDir: 'dist', + + /** + * Pack filename format. + */ + outFileFmt: 'debug-stick.{version}.mcpack', + + /** + * Pack name. + */ + packName: 'Debug Stick', + + /** + * The current version. + */ + packVersion: '26.10.0', + + /** + * Minimum Minecraft version required. + */ + minMcVersion: '1.26.10', + + /** + * Script entry point. + */ + scriptEntry: 'scripts/index.js', + + /** + * Pack description. + */ + packDescription: [ + '§7v{version} ({shCommit}) MCBE {minMcVer}+§r', + '', + 'Java §dDebug Stick§r ported to Minecraft: Bedrock Edition, by §bvytdev§r', + 'Use §a/give @s vyt:debug_stick§r to get the Debug Stick.', + '', + 'Report bugs here: §bhttps://github.com/vytdev/debug-stick/§r', + 'Copyright (c) 2023-2026 Vincent Yanzee J. Tan', + 'Licensed under the MIT License.', + ].join('\n'), + + /** + * Dependencies. + */ + dependencies: { + '@minecraft/server': '2.6.0', + //'@minecraft/server-ui': '2.0.0', + } +}; diff --git a/pack/LICENSE b/pack/LICENSE deleted file mode 100644 index 105955e..0000000 --- a/pack/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023-2026 Vincent Yanzee J. Tan - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/pack/manifest.json b/pack/manifest.json deleted file mode 100644 index 9bff1a2..0000000 --- a/pack/manifest.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "format_version": 3, - - "header": { - "name": "Debug Stick", - "description": "Java §dDebug Stick§r ported to Minecraft: Bedrock Edition, by §bvytdev§r\nUse §a/give @s vyt:debug_stick§r or find it in the inventory screen to obtain the item.\n\nReport bugs here: §bhttps://github.com/vytdev/debug-stick/§r\nCopyright (c) 2023-2026 Vincent Yanzee J. Tan\nLicensed under the MIT License.", - "uuid": "21aadfa6-e27c-400c-c596-596021852939", - "version": "26.10.0", - "min_engine_version": "1.26.10" - }, - - "modules": [ - { - "description": "behaviour", - "type": "data", - "uuid": "d8a9ff21-7aa3-4b83-73ed-eeb141516e74", - "version": "1.0.0" - }, - { - "description": "scripting", - "type": "script", - "language": "javascript", - "entry": "scripts/index.js", - "uuid": "86c7bab4-aed9-4297-5f0c-d5d62bd30be1", - "version": "1.0.0" - } - ], - - "dependencies": [ - { - "module_name": "@minecraft/server", - "version": "2.6.0" - } - ], - - "metadata": { - "authors": [ "VYT" ], - "license": "MIT", - "url": "https://github.com/vytdev/debug-stick" - } -} - diff --git a/package-lock.json b/package-lock.json index f79013b..d3cc7b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "debug-stick", "dependencies": { - "@minecraft/server": "^2.6.0" + "@minecraft/server": "^2.6.0", + "@minecraft/server-ui": "^2.0.0", + "@minecraft/vanilla-data": "^1.26.12" }, "devDependencies": { "jszip": "^3.10.1", @@ -17,8 +19,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@minecraft/common/-/common-1.2.0.tgz", "integrity": "sha512-JdmEq4P3Z/FtoBzhLijFgMSVFnFRrUoLwY8DHHrgtFo0mfLTOLTB1RErYjLMsA6b7BGVNxkX/pfFRiH7QZ0XwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@minecraft/server": { "version": "2.6.0", @@ -30,12 +31,21 @@ "@minecraft/vanilla-data": ">=1.20.70" } }, - "node_modules/@minecraft/vanilla-data": { - "version": "1.26.2", - "resolved": "https://registry.npmjs.org/@minecraft/vanilla-data/-/vanilla-data-1.26.2.tgz", - "integrity": "sha512-nxACCFgbcE9D5Z5vRnl/AtJljat7yC4vKzZ/t87iHqdersZlDhBzUnfBDH4d1F6Le5vgdSEUIJwUJxKR+Yc3+w==", + "node_modules/@minecraft/server-ui": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@minecraft/server-ui/-/server-ui-2.0.0.tgz", + "integrity": "sha512-K1xFK1OEJSNfIgyRElaoxDTW/nbEqjBp1YnUdyPCs4kGDglwoXNyFz3wzMOkvT04CJXSifZGcTJeWda4C6OkXw==", "license": "MIT", - "peer": true + "dependencies": { + "@minecraft/common": "^1.0.0", + "@minecraft/server": "^2.0.0" + } + }, + "node_modules/@minecraft/vanilla-data": { + "version": "1.26.12", + "resolved": "https://registry.npmjs.org/@minecraft/vanilla-data/-/vanilla-data-1.26.12.tgz", + "integrity": "sha512-QT6bkvgrLN3lVU0KxwDs2bvRZTs2QRrR6ShET/3CMzycBtdEv/CcQV1KSQXYD3WRJNIxS57gmq8+OQ0S9o5+Yg==", + "license": "MIT" }, "node_modules/core-util-is": { "version": "1.0.3", diff --git a/package.json b/package.json index b0583ad..0e5d9d3 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,14 @@ "type": "module", "scripts": { "test": "npx tsc", - "build": "node tool.js build pack", - "clean": "node tool.js clean", - "watch": "node tool.js watch" + "build": "./tool build pack", + "clean": "./tool clean", + "watch": "./tool watch" }, "dependencies": { - "@minecraft/server": "^2.6.0" + "@minecraft/server": "^2.6.0", + "@minecraft/server-ui": "^2.0.0", + "@minecraft/vanilla-data": "^1.26.12" }, "devDependencies": { "jszip": "^3.10.1", diff --git a/src/config.d.ts b/src/config.d.ts new file mode 100644 index 0000000..7b6d93a --- /dev/null +++ b/src/config.d.ts @@ -0,0 +1,52 @@ +/** + * Type definition of the auto-generated config.js file. + */ + +/** + * @interface + * The default-exported object. + */ +export interface Config { + + /** + * The add-on's version. + */ + version: string, + + /** + * Minecraft `min_engine_version`. + */ + minMcVer: string, + + /** + * @minecraft/server version. + */ + apiVer: string, + + /** + * @minecraft/server-ui version + */ + uiApiVer: string, + + /** + * Which branch this build was from? + */ + branch: string, + + /** + * The exact git commit when this build was made. + */ + commit: string, + + /** + * Shortened version of commit. + */ + shCommit: string, +} + +declare const config: Config; + +/** + * Auto-generated build config. + */ +export default config; diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..24a3367 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,162 @@ +/*! + * Debug Stick -- A Bedrock port of the debug stick tool from Java Edition. + * Copyright (c) 2023-2026 Vincent Yanzee J. Tan + * + * This project is licensed under the MIT License. + * This software is provided "as is" without warranty of any kind. + * See LICENSE for the full terms. + */ + +import { + Block, + BlockPermutation, + BlockStates, + LiquidType, + Player, + RawMessage +} from '@minecraft/server'; + +import { + BlockStateSuperset +} from '@minecraft/vanilla-data'; + +// NOTE: For Java parity, we have to specially handle the 'waterlogged' +// property since it doesn't exist as a block state. + + +/** + * @type + * Block type identifier. + */ +export type BlockType = string; + +/** + * @type + * Block property name. + */ +export type PropName = string; + +/** + * @type + * Block property value. + */ +export type PropValue = string | number | boolean; + +/** + * @type + * Map of property names and values. + */ +export type PropMap = Record; + + + +/** + * @class DebugStickContext + * Context passed to the debug-stick-related event handlers. + */ +export class DebugStickContext { + + /** + * Creates a new {@link DebugStickContext} instance. + * @param block The block you'll be working on. + * @param player The player who initiated the event. + * @param event The corresponding event that triggered the callback. + */ + constructor(block: Block, player: Player, event: T) { + this.block = block; + this.player = player; + this.event = event; + } + + /** + * The block we're working on. + */ + readonly block: Block; + + /** + * The player who initiated the event. + */ + readonly player: Player; + + /** + * The event. + */ + readonly event: T; + + + /** + * Returns an array of valid property values for the given property. + * @param prop The property name. + * @returns Valid prop values. + */ + static getPropValidValues(prop: PropName): PropValue[] { + if (prop === 'waterlogged') + return [false, true]; + return BlockStates.get(prop).validValues; + } + + + /** + * Returns all the properties of a block. + * @returns Record + */ + getAllProps(): PropMap { + const props = this.block.permutation.getAllStates() || {}; + if (this.block.canContainLiquid(LiquidType.Water)) + props['waterlogged'] = this.block.isWaterlogged; + return props; + } + + + /** + * Updates a block's property. + * Note: Can't work in read-only mode; defer execution. + * @param prop The property to change. + * @param value The value to set. + */ + setBlockProp(prop: PropName, value: PropValue): void { + if (prop == 'waterlogged') + this.block.setWaterlogged(value as boolean); + else + this.block.setPermutation(this.block.permutation.withState( + prop as keyof BlockStateSuperset, value)); + } + + + /** + * Sets all the properties of a block. + * Note: Can't work in read-only mode; defer execution. + * @param propMap Map of property values. + */ + setProps(propMap: PropMap): void { + const stateMap: PropMap = {}; // really BlockStateSuperset + // BlockPermutation.resolve() does not tolerate unknown state names + for (const propName in propMap) + if (propName != 'waterlogged') + stateMap[propName] = propMap[propName]; + // set .isWaterlogged separately + if ('waterlogged' in propMap) + this.block.setWaterlogged(propMap['waterlogged'] as boolean); + this.block.setPermutation( + BlockPermutation.resolve(this.block.typeId, propMap)); + } + + + /** + * Sends a message to player's actionbar. + * Note: Can't work in read-only mode; defer execution. + * @param msg The message. + */ + notify(msg: (RawMessage | string)[] | RawMessage | string): void { + this.player.onScreenDisplay.setActionBar(msg); + } + + + /** + * Sends a message to player's chat screen. + * @param msg The message. + */ + message(msg: (RawMessage | string)[] | RawMessage | string): void { + this.player.sendMessage(msg); + } +} diff --git a/src/debug-stick.ts b/src/debug-stick.ts new file mode 100644 index 0000000..20f5a7c --- /dev/null +++ b/src/debug-stick.ts @@ -0,0 +1,179 @@ +/*! + * Debug Stick -- A Bedrock port of the debug stick tool from Java Edition. + * Copyright (c) 2023-2026 Vincent Yanzee J. Tan + * + * This project is licensed under the MIT License. + * This software is provided "as is" without warranty of any kind. + * See LICENSE for the full terms. + */ + +import { DebugPropertySelections } from './selection.js'; +import { DebugStickContext } from './context.js'; +import { cycleArray, defer, safeCall } from './utils.js'; + +import { + PlayerBreakBlockBeforeEvent, + PlayerInteractWithBlockBeforeEvent, + world, +} from '@minecraft/server'; + + +/** + * The debug stick's item identifier. + */ +export const DEBUG_STICK_ID = 'vyt:debug_stick'; + + +/** + * Change the selected block state. + * @param ctx + */ +export function changeSelectedProperty(ctx: + DebugStickContext) +{ + const props = ctx.getAllProps(); + const propNames = Object.keys(props); + if (!propNames.length) + return ctx.notify(`${ctx.block.typeId} has no properties`); + + // Cycle through all property names. + const sels = new DebugPropertySelections(ctx.player.id); + let currProp = sels.getForBlock(ctx.block.typeId); + currProp = cycleArray(propNames, currProp); + sels.setForBlock(ctx.block.typeId, currProp); + + ctx.notify(`selected "${currProp}" (${props[currProp]})`); +} + + +/** + * Cycle the state value of the selected state on a block. + * @param ctx + */ +export function updateBlockProperty(ctx: + DebugStickContext) +{ + const props = ctx.getAllProps(); + const propNames = Object.keys(props); + if (!propNames.length) + return ctx.notify(`${ctx.block.typeId} has no properties`); + + // Get the currenty selected property. + const sels = new DebugPropertySelections(ctx.player.id); + const currProp = sels.getForBlock(ctx.block.typeId) ?? propNames[0]; + + // Cycle through property values. + const validVals = DebugStickContext.getPropValidValues(currProp); + const newVal = cycleArray(validVals, props[currProp]); + + ctx.setBlockProp(currProp, newVal); + ctx.notify(`"${currProp}" to ${newVal}`); +} + + +/** + * The block viewer feature + * @param ctx + */ +export function displayBlockInfo(ctx: + DebugStickContext) +{ + const block = ctx.block; + let info = '§l§b' + block.typeId + '§r'; + + // Basic block info. + info += '\n§4' + block.x + ' §a' + block.y + ' §9' + block.z; + info += '\n§o§7redstone power§r§8: §c' + (block.getRedstonePower() ?? 0); + + // The set block states. + for (const [prop, value] of Object.entries(ctx.getAllProps())) { + info += '\n§7' + prop + '§r§8: '; + switch (typeof value) { + case 'string': info += '§e'; break; + case 'number': info += '§3'; break; + case 'boolean': info += '§6'; break; + default: info += '§8'; + } + info += value; + }; + + // Additional block tags. + block.getTags().forEach(v => info += '\n§d#' + v); + + ctx.notify(info); +} + + +// Java behaviour: +// - left-click = select property +// - right-click = change state value +// - shift + (left-or-right-)click = cycle in reverse + +// This behaviour: +// - left-click = select property +// - right-click = change state value +// - shift + right-click = block viewer + + + +let isEnabled = false +let blockInteractListener: any; +let breakBlockListener: any; + + +/** + * Registers event listeners for vyt:debug_stick. + */ +export function enableDebugStick() { + if (isEnabled) + return; + isEnabled = true; + + // Short tap/click triggers. + blockInteractListener = world.beforeEvents + .playerInteractWithBlock.subscribe(ev => + { + if (ev.itemStack?.typeId != DEBUG_STICK_ID) + return; + ev.cancel = true; + const ctx = new DebugStickContext(ev.block, ev.player, ev); + defer(() => { + let isError, result; + if (ev.player.isSneaking) + [isError, result] = safeCall(displayBlockInfo, ctx); + else + [isError, result] = safeCall(updateBlockProperty, ctx); + if (isError) + ev.player.sendMessage('§c' + result); + }); + }); + + // Long press/block break triggers. + breakBlockListener = world.beforeEvents + .playerBreakBlock.subscribe(ev => + { + if (ev.itemStack?.typeId != DEBUG_STICK_ID) + return; + ev.cancel = true; + const ctx = new DebugStickContext(ev.block, ev.player, ev); + defer(() => { + let [isError, result] = safeCall(changeSelectedProperty, ctx); + if (isError) + ev.player.sendMessage('§c' + result); + }); + }); +} + + +/** + * Deregisters event listeners for vyt:debug_stick. + */ +export function disableDebugStick() { + if (!isEnabled) + return; + isEnabled = false; + world.beforeEvents.playerInteractWithBlock + .unsubscribe(blockInteractListener); + world.beforeEvents.playerBreakBlock + .unsubscribe(breakBlockListener); +} diff --git a/src/handlers.ts b/src/handlers.ts deleted file mode 100644 index 739e1ab..0000000 --- a/src/handlers.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*! - * Debug Stick -- A Bedrock port of the debug stick tool from Java Edition. - * Copyright (c) 2023-2026 Vincent Yanzee J. Tan - * - * This project is licensed under the MIT License. - * This software is provided "as is" without warranty of any kind. - * See LICENSE for the full terms. - */ - -import { Block, Player } from '@minecraft/server'; -import { message } from './utils.js'; -import { getStatesOfBlock, getStateValidValues, setBlockState - } from './state.js'; -import { DebugStateSelections } from './selection.js'; - - -/** - * Change the selected block state. - * @param player The player who initiated the change. - * @param block The block the player is working with. - * @param item The debug stick item used. - */ -export function changeSelectedProperty(player: Player, block: Block) { - const states = getStatesOfBlock(block); - const stateNames = Object.keys(states); - if (!stateNames.length) - return message(`${block.typeId} has no properties`, player); - - // Cycle through the possible states. - const selections = new DebugStateSelections(player.id); - let currState = selections.getSelectedStateForBlock(block.typeId); - currState = stateNames[(stateNames.indexOf(currState) + 1) - % stateNames.length]; - selections.setSelectedStateForBlock(block.typeId, currState); - - message(`selected "${currState}" (${states[currState]})`, player); -} - - -/** - * Cycle the state value of the selected state on a block. - * @param player The player who initiated the cycle. - * @param block The block to update with the debug stick. - * @param item The debug stick item used. - */ -export function updateBlockProperty(player: Player, block: Block) { - const states = getStatesOfBlock(block); - const stateNames = Object.keys(states); - if (!stateNames.length) - return message(`${block.typeId} has no properties`, player); - - // Get the currently selected state. - const selections = new DebugStateSelections(player.id); - const currState = selections.getSelectedStateForBlock(block.typeId) - ?? stateNames[0]; - - // Cycle through valid state values. - const valids = getStateValidValues(currState); - const value = valids[(valids.indexOf(states[currState]) + 1) % valids.length]; - - setBlockState(block, currState, value) - .then(() => message(`"${currState}" to ${value}`, player)); -} - - -/** - * The block viewer feature - * @param player - * @param block - */ -export function displayBlockInfo(player: Player, block: Block) { - let info = '§l§b' + block.typeId + '§r'; - - // Basic block info. - info += '\n§4' + block.x + ' §a' + block.y + ' §9' + block.z; - info += '\n§o§7redstone power§r§8: §c' + (block.getRedstonePower() ?? 0); - - // The set block states. - for (const [stateName, value] of Object.entries(getStatesOfBlock(block))) { - info += '\n§7' + stateName + '§r§8: '; - switch (typeof value) { - case 'string': info += '§e'; break; - case 'number': info += '§3'; break; - case 'boolean': info += '§6'; break; - default: info += '§8'; - } - info += value; - }; - - // Additional block tags. - block.getTags().forEach(v => info += '\n§d#' + v); - - message(info, player); -} diff --git a/src/index.ts b/src/index.ts index 5e7d359..6f4e519 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,43 +7,6 @@ * See LICENSE for the full terms. */ -import { world } from '@minecraft/server'; -import { safeCallWrapper } from './utils.js'; -import { changeSelectedProperty, displayBlockInfo, updateBlockProperty - } from './handlers.js'; +import { enableDebugStick } from './debug-stick.js'; -/** - * The item identifier of debug stick. - */ -const DEBUG_STICK_ID = 'vyt:debug_stick'; - -// Java behaviour: -// - left-click = select property -// - right-click = change state value -// - shift + (left-or-right-)click = cycle in reverse - -// This behaviour: -// - left-click = select property -// - right-click = change state value -// - shift + right-click = block viewer - - -// Short tap/click triggers. -world.beforeEvents.playerInteractWithBlock.subscribe(safeCallWrapper((ev) => { - if (ev.itemStack?.typeId != DEBUG_STICK_ID) - return; - ev.cancel = true; - if (ev.player.isSneaking) - displayBlockInfo(ev.player, ev.block); - else - updateBlockProperty(ev.player, ev.block); -})); - - -// Long press/block break triggers. -world.beforeEvents.playerBreakBlock.subscribe(safeCallWrapper((ev) => { - if (ev.itemStack?.typeId != DEBUG_STICK_ID) - return; - ev.cancel = true; - changeSelectedProperty(ev.player, ev.block); -})); +enableDebugStick(); diff --git a/src/selection.ts b/src/selection.ts index 8630e29..bf02726 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -7,21 +7,24 @@ * See LICENSE for the full terms. */ -import type { BlockType, StateName } from './types'; +import { + BlockType, + PropName +} from './context.js'; // TODO: try dynamic properties -const record: Record> = {}; +const record: Record> = {}; /** * @class - * Manages the selected states of a debug stick item. + * Manages the selected properties of a debug stick item. */ -export class DebugStateSelections { +export class DebugPropertySelections { /** * @constructor - * Creates a new DebugStateSelections instance. + * Creates a new {@link DebugPropertySelections} instance. * @param id The persistence ID. */ constructor(id: string) { @@ -37,29 +40,29 @@ export class DebugStateSelections { /** * @private - * A table of selected block states. + * A table of selected block properties. */ - private _selections: Record; + private _selections: Record; /** - * Get the currently selected state of the debug stick tool for the given + * Get the currently selected prop of the debug stick tool for the given * block type. * @param blockType The block type identifier. - * @returns The selected state or null if not yet set. + * @returns The selected prop or null if not yet set. */ - getSelectedStateForBlock(blockType: BlockType): StateName | null { + getForBlock(blockType: BlockType): PropName | null { return this._selections[blockType] ?? null; } /** - * Set the currently selected state of the debug stick tool for the given + * Set the currently selected prop of the debug stick tool for the given * block type. * @param blockType The block type identifier. - * @param stateName The name of the block state to be set as selected. + * @param prop The name of the property to be set as selected. */ - setSelectedStateForBlock(blockType: BlockType, stateName: StateName): void { - this._selections[blockType] = stateName; + setForBlock(blockType: BlockType, prop: PropName): void { + this._selections[blockType] = prop; } } diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index 326c2a3..0000000 --- a/src/state.ts +++ /dev/null @@ -1,65 +0,0 @@ -/*! - * Debug Stick -- A Bedrock port of the debug stick tool from Java Edition. - * Copyright (c) 2023-2026 Vincent Yanzee J. Tan - * - * This project is licensed under the MIT License. - * This software is provided "as is" without warranty of any kind. - * See LICENSE for the full terms. - */ - -import { Block, BlockStates, LiquidType, system } from '@minecraft/server'; -import { BlockStateSuperset } from '@minecraft/vanilla-data'; -import type { StateName, StateValue } from './types'; - -// NOTE: For Java parity, we have to specially handle the 'waterlogged' -// property until it's alright :] - - -/** - * Returns an array of valid block state values for the given block state. - * @param stateName The block state name. - * @returns Valid block state values. - */ -export function getStateValidValues(stateName: StateName): StateValue[] { - if (stateName === 'waterlogged') - return [false, true]; - return BlockStates.get(stateName).validValues; -} - - -/** - * Returns all the block states of a block. - * @param block The block. - * @returns Record - */ -export function getStatesOfBlock(block: Block): Record { - const states = block.permutation.getAllStates() || {}; - if (block.canContainLiquid(LiquidType.Water)) - states['waterlogged'] = block.isWaterlogged; - return states; -} - - -/** - * Set a block's state. - * @param block The block to update. - * @param stateName The name of the state to change. - * @param value The value to set. - * @returns Promise - */ -export function setBlockState(block: Block, stateName: StateName, - value: StateValue): Promise { - return new Promise((res, rej) => system.run(() => { - try { - if (stateName == 'waterlogged') - block.setWaterlogged(value as boolean); - else - block.setPermutation(block.permutation.withState( - stateName as keyof BlockStateSuperset, value)); - res(); - } - catch (e) { - rej(e); - } - })); -} diff --git a/src/types.d.ts b/src/types.d.ts deleted file mode 100644 index 8aed155..0000000 --- a/src/types.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * Debug Stick -- A Bedrock port of the debug stick tool from Java Edition. - * Copyright (c) 2023-2026 Vincent Yanzee J. Tan - * - * This project is licensed under the MIT License. - * This software is provided "as is" without warranty of any kind. - * See LICENSE for the full terms. - */ - -/** - * @type - * Block type identifier. - */ -export type BlockType = string; - -/** - * @type - * Block state name. - */ -export type StateName = string; - -/** - * @type - * Block state value. - */ -export type StateValue = string | number | boolean; diff --git a/src/utils.ts b/src/utils.ts index e84f6bf..2a03907 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,25 +7,39 @@ * See LICENSE for the full terms. */ -import { Player, system } from '@minecraft/server'; +import { + system +} from '@minecraft/server'; + +import config from './config.js'; /** - * Message a player into their actionbar - * @param msg The message - * @param player The player to message + * Defer the execution of a function. + * @param func The function to delay. + * @param args Arguments. + * @returns Promise with the result of the function. */ -export async function message(msg: string, player: Player) { +export function defer( + func: (...args: A) => R, ...args: A): Promise +{ return new Promise((res, rej) => { system.run(() => { - try { - res(player.onScreenDisplay.setActionBar(msg)); - } - catch (e) { - rej(e); - } - }); - }); + try { res(func.apply({}, args)); } + catch (e) { rej(e); } + }) + }) +} + + +/** + * Cycle through an array. + * @param arr The array. + * @param curr The current value in the array. + * @returns Next value. + */ +export function cycleArray(arr: Array, curr: T): T { + return arr[(arr.indexOf(curr) + 1) % arr.length]; } @@ -33,18 +47,26 @@ export async function message(msg: string, player: Player) { * Safely call a function. Catch errors into content log * @param func The function * @param args Arguments of the function - * @returns Whatever that function will return + * @returns [isError, result] */ export function safeCall( - func: (...args: A) => R, ...args: A): R | undefined { + func: (...args: A) => R, ...args: A): [false, R] | [true, string] +{ try { - return func.apply({}, args); + return [false, func.apply({}, args)]; } catch (e) { let msg = 'DEBUG STICK ERROR\n'; msg += 'Please report this issue on GitHub:\n'; msg += ' https://github.com/vytdev/debug-stick/issues/new\n'; msg += '\n'; + msg += 'add-on version: ' + config.version + '\n'; + msg += '@minecraft/server: ' + config.apiVer + '\n'; + msg += '@minecraft/server-ui: ' + config.uiApiVer + '\n'; + msg += 'min_engine_version: ' + config.minMcVer + '\n'; + msg += 'branch: ' + config.branch + '\n'; + msg += 'commit: ' + config.shCommit + '\n'; + msg += '\n'; msg += e; @@ -52,18 +74,6 @@ export function safeCall( msg += `\n${e.stack}`; console.error(msg); - } -} - - -/** - * Safe call wrapper function. - * @param func The function to wrap - * @returns A function - */ -export function safeCallWrapper( - func: (...args: A) => R): ((...args: A) => R | undefined) { - return function (...args: A): R | undefined { - return safeCall(func, ...args); + return [true, msg]; } } diff --git a/tool b/tool new file mode 100755 index 0000000..4cd0790 --- /dev/null +++ b/tool @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import path from 'path'; +// We must run from the repo folder. +process.chdir(path.dirname(process.argv[1])); +import('./build/main.js'); diff --git a/tool.js b/tool.js deleted file mode 100755 index 77aab4e..0000000 --- a/tool.js +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -import child_process from 'child_process'; -import fs from 'fs/promises'; -import fsSync from 'fs'; -import path from 'path'; -import JSZip from 'jszip'; -import { isAsyncFunction } from 'util/types'; - -import tsconfig from './tsconfig.json' with { type: 'json' }; -import manifest from './pack/manifest.json' with { type: 'json' }; - -const packVersion = manifest.header.version; -const packMinEngineVersion = manifest.header.min_engine_version; -const actionTable = {}; - - -/* --- HELP --- */ -actionTable['help'] = () => console.error( - `usage: ${process.argv[1]} [task...]\n` + - 'Utility script for working with the debug-stick project.\n' + - 'Available tasks:\n' + - ' help Shows this help\n' + - ' build Run: npx tsc --build\n' + - ' watch Run: npx tsc --watch\n' + - ' clean Remove generated files\n' + - ' pack Generate dist packages\n' + - '@vytdev' - ); - - -/* --- BUILD --- */ -actionTable['build'] = async () => { - try { - const code = await runProcessAsync('npx', 'tsc', '--build'); - console.log('typescript exited with code: ' + code); - return code; - } - catch (e) { - printErr(e); - return -1; - } -}; - - -/* --- WATCH --- */ -actionTable['watch'] = async () => { - try { - const code = await runProcessAsync('npx', 'tsc', '--watch'); - console.log('typescript dev server exited with code: ' + code); - return code; - } - catch (e) { - printErr(e); - return -1; - } -}; - - -/* --- CLEAN --- */ -actionTable['clean'] = async () => { - await fs.rm(tsconfig.compilerOptions.outDir, { - force: true, - recursive: true - }); - console.log('cleanup complete'); -}; - - -/* --- PACK --- */ -actionTable['pack'] = async () => { - const zipName = `debug-stick.${packVersion}.zip`; - const mcName = `debug-stick.${packVersion}.mcpack`; - // name format for github - await zipFolder('pack', zipName); - fs.copyFile(zipName, mcName); - console.log('created distribution packages'); -}; - - -/** - * Error printing utility. - * @param msgs The messages. - */ -function printErr(...msgs) { - console.error(`${process.argv[1]}:`, ...msgs); -} - - -/** - * Asynchronously run a sub-process. - * @param arg0 Name of or path to the executable. - * @param args Arguments to pass to the process. - * @returns A Promise. - */ -async function runProcessAsync(arg0, ...args) { - return new Promise((resolve, reject) => { - const subproc = child_process.spawn(arg0, args, { stdio: 'inherit' }); - // redirect sigint temporarily - const sigIntHandler = () => subproc.kill('SIGINT'); - process.on('SIGINT', sigIntHandler); - // handle success and failure - subproc.on('close', code => { - process.off('SIGINT', sigIntHandler); - resolve(code || 0); - }); - subproc.on('error', err => { - process.off('SIGINT', sigIntHandler); - reject(err) - }); - }); -} - - -/** - * Zip an entire directory. - * @param folderPath The folder to zip. - * @param outPath Where to save the zip file. - */ -async function zipFolder(folderPath, outPath) { - const zip = new JSZip(); - function addDirToZip(zipObj, folder) { - const items = fsSync.readdirSync(folder); - for (const item of items) { - const fullPath = path.join(folder, item); - const stats = fsSync.statSync(fullPath); - if (stats.isDirectory()) { - const subFolder = zipObj.folder(item); - addDirToZip(subFolder, fullPath); - } else { - const data = fsSync.readFileSync(fullPath); - zipObj.file(item, data); - } - } - } - addDirToZip(zip, folderPath); - const content = await zip.generateAsync({ type: 'nodebuffer' }); - fsSync.writeFileSync(outPath, content); -} - - -// We must run from the repo folder. -process.chdir(path.dirname(process.argv[1])); - -// Run each task in given order. -for (const task of process.argv.slice(2)) { - const fn = actionTable[task]; - if (typeof fn !== 'function') { - printErr('task does not exist: ' + task); - continue; - } - - // run the task synchronously - console.log(`--- ${task} ---`); - let code = isAsyncFunction(fn) ? await fn(task) : fn(task); - code = (code || 0) & 0xff; - if (code != 0) process.exit(code); -} diff --git a/tsconfig.json b/tsconfig.json index e791cbe..c71ebab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,21 @@ -{ - "compilerOptions": { - "module": "es2020", - "moduleResolution": "node", - "noImplicitAny": true, - "noImplicitThis": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "target": "es2022", - "lib": [ "es2020", "dom" ], - "preserveConstEnums": true, - "sourceMap": false, - "outDir": "./pack/scripts", - "declaration": false, - "allowJs": true, - "baseUrl": "./src" - }, - "include": [ - "./src" - ] -} - +{ + "compilerOptions": { + "module": "es2020", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitThis": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "target": "es2022", + "lib": [ "es2020", "dom" ], + "preserveConstEnums": true, + "sourceMap": false, + "outDir": "./dist/js-out", + "declaration": false, + "allowJs": true, + "baseUrl": "./src" + }, + "include": ["./src"] +}