diff --git a/README.md b/README.md index 36b2827..1573aa2 100644 --- a/README.md +++ b/README.md @@ -2,71 +2,42 @@ Canopy Extension Example Icon -

Canopy Extension Example

+

StrucTool

-[![Codacy Quality](https://app.codacy.com/project/badge/Grade/10040a714ad84a2f912d4dae9d3f6e57)](https://app.codacy.com/gh/ForestOfLight/Canopy-Extension-Example/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Codacy Quality](https://app.codacy.com/project/badge/Grade/10040a714ad84a2f912d4dae9d3f6e57)](https://app.codacy.com/gh/ForestOfLight/StrucTool/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Minecraft - Version](https://img.shields.io/badge/Minecraft-v1.21.60_(Bedrock)-brightgreen)](https://feedback.minecraft.net/hc/en-us/sections/360001186971-Release-Changelogs) -[![GitHub License](https://img.shields.io/github/license/forestoflight/canopy-extension-example)](LICENSE) +[![GitHub License](https://img.shields.io/github/license/forestoflight/structool)](LICENSE) [![Discord](https://badgen.net/discord/members/9KGche8fxm?icon=discord&label=Discord&list=what)](https://discord.gg/9KGche8fxm) --- -Extensions?! In a behavior pack?! Yep! This repo contains an example pack designed to teach you how to create your own extensions for **Canopy**. All extensions must be added to the same world as **Canopy** (the dependencies in the `manifest.json` should ensure that). +Intro paragraph. -## How this example pack functions ingame +> [!IMPORTANT] +> This addon is a **Canopy Extension**, which means **Canopy** must be installed in your world for it to work. +> [Download **Canopy** here!](https://github.com/ForestOfLight/Canopy) -What **Canopy** handles: +## Usage -**`./canopy [true/false]`** -**Canopy** will automatically include the names of your rules in the `./canopy` command. +All commands are prefixed with `./`. Do `./help` for more information. -**`./help`** -**Canopy** will automatically include a Rules and a Commands help page for your extension. +**Usage: `./struct add `** +Adds a new structure. -![Help Page](./example_help.png) +**Usage: `./struct remove `** +Removes a structure. -What the extension handles: +## Roadmap -**`./example [message]`** -This command sends a message to the chat. It also has a default message if no message is provided. (Alias: `./ex`) - -**`commandExample`** -This rule will enable the `./example` command. - -**`exampleRule`** -This rule will enable a message in chat when you push a button. - -## How to create an extension - -1. Clone this repo. - ```sh - git clone https://github.com/ForestOfLight/canopy-extension-example.git - ``` -2. Remove all files that are not the `pack_icon.png`, the `manifest.json` file, or in the `scripts/` folder. -3. Rename the folder to your extension's name. - ```sh - mv canopy-extension-example your-extension-name - ``` -4. Open the folder in your favorite code editor. -5. Edit the `manifest.json` file to match your extension's information. -6. Replace the `pack_icon.png` with your extension's icon. -7. Edit the `main.js` file to add your extension's functionality. - -> [!TIP] -> Most information about how to code a **Canopy Extension** can be found in the `main.js` file. - -## How to use an extension - -1. Add the extension pack to the same world as the **Canopy** RP & BP. -2. Profit. - -**Canopy** will automatically detect the extension and load it! - -## How does this work? - -**Canopy** and its extensions all communicate using scriptevents! The vanilla `/scriptevent` command allows for sending strings to Behavior Packs and the `ScriptEventCommandMessage` event allows for recieving them. **Canopy** uses [MCBE-IPC](https://https://github.com/OmniacDev/MCBE-IPC), a library that streamlines the process of sending and recieving scriptevents. -**Canopy Extensions** use these to communicate with **Canopy** when new rules and commands are added. **Canopy** uses these as well to get rule data, set rule data, and call command callbacks from extensions. +- [ ] Add new structures by pasting a string +- [ ] Make a viewable material list +- [ ] structuraMode & holoprintMode rules for ease of use +- [ ] Make automatic material grabbing from inventories +- [ ] structure placement (ghost and solid) +- [ ] easyPlace rule +- [ ] more litematica features! +- [ ] send structure as a scriptevent so that other addons can use it ## Issues & Suggestions diff --git a/example_help.png b/example_help.png deleted file mode 100644 index 4889a69..0000000 Binary files a/example_help.png and /dev/null differ diff --git a/manifest.json b/manifest.json index c4b9575..ffc6aa4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "format_version": 2, "header": { - "name": "Canopy Extension Example", - "description": "Example extension for §l§aCanopy§r by §aForestOfLight§r.", - "uuid": "e4e844d5-f45b-4623-82a7-534d44bb4c6f", + "name": "StrucTool", + "description": "Survival building extension for §l§aCanopy§r by §aForestOfLight§r.", + "uuid": "8c0c0153-d8b9-482a-889f-aef922b8fe58", "min_engine_version": [1, 21, 60], "version": [1, 0, 0] }, @@ -11,7 +11,7 @@ { "description": "Behavior Pack Module", "type": "data", - "uuid": "1ce9eab8-0d4a-4e40-a899-575791a1b40d", + "uuid": "f4d52ae1-2c26-4938-b8c2-7e455d495620", "version": [1, 0, 0] }, { @@ -19,7 +19,7 @@ "type": "script", "language": "javascript", "entry": "scripts/main.js", - "uuid": "839a937e-b4f9-4484-84a4-0db984822f25", + "uuid": "8bb9a1e4-0531-4b93-8ade-3880bfe1e2fc", "version": [1, 0, 0] } ], @@ -28,6 +28,10 @@ "module_name": "@minecraft/server", "version": "1.18.0-beta" }, + { + "module_name": "@minecraft/server-ui", + "version": "1.4.0-beta" + }, { "uuid": "bcf34368-ed0c-4cf7-938e-582cccf9950d", // Canopy RP "version": [1, 0, 2] @@ -36,5 +40,9 @@ "uuid": "7f6b23df-a583-476b-b0e4-87457e65f7c0", // Canopy BP "version": [1, 3, 8] } - ] + ], + "metadata": { + "authors": [ "ForestOfLight" ], + "license": "MIT" + } } diff --git a/scripts/classes/Outliner.js b/scripts/classes/Outliner.js new file mode 100644 index 0000000..18d2a61 --- /dev/null +++ b/scripts/classes/Outliner.js @@ -0,0 +1,82 @@ +import { MolangVariableMap, system, world } from "@minecraft/server"; +import { Vector } from "../lib/Vector"; + +const drawFrequency = 8; +const drawParticle = "minecraft:villager_happy"; + +export class Outliner { + dimension; + min = new Vector(); + max = new Vector(); + #shouldDraw = true; + + #drawParticles = []; + #runner = null; + + constructor(dimension, min, max) { + this.dimension = world.getDimension(dimension); + this.min = new Vector(min.x, min.y, min.z); + this.max = new Vector(max.x, max.y, max.z); + } + + startDraw() { + this.#shouldDraw = true; + this.#runner = system.runInterval(() => this.draw(), drawFrequency); + } + + stopDraw() { + system.clearRun(this.#runner); + this.shouldDraw = false; + } + + draw() { + if (!this.#shouldDraw) return; + this.#drawParticles.length = 0; + this.#drawParticles.push(...this.getCubiodParticleLocations()); + + for (const [particleType, location] of this.#drawParticles) { + try { + this.dimension.spawnParticle(particleType, location); + } catch { + /* pass */ + } + } + } + + getCubiodParticleLocations() { + const vertices = [ + new Vector(this.min.x, this.min.y, this.min.z), + new Vector(this.max.x, this.min.y, this.min.z), + new Vector(this.min.x, this.max.y, this.min.z), + new Vector(this.max.x, this.max.y, this.min.z), + new Vector(this.min.x, this.min.y, this.max.z), + new Vector(this.max.x, this.min.y, this.max.z), + new Vector(this.min.x, this.max.y, this.max.z), + new Vector(this.max.x, this.max.y, this.max.z) + ]; + const edges = [ + [0, 1], + [0, 2], + [0, 4], + [1, 3], + [1, 5], + [2, 3], + [2, 6], + [3, 7], + [4, 5], + [4, 6], + [5, 7], + [6, 7] + ]; + const edgePoints = []; + for (const edge of edges) { + const [startVertex, endVertex] = [vertices[edge[0]], vertices[edge[1]]]; + const resolution = Math.min(Math.floor(endVertex.subtract(startVertex).length), 16); + for (let i = 1; i < resolution; i++) { + const t = i / resolution; + edgePoints.push(startVertex.lerp(endVertex, t)); + } + } + return vertices.concat(edgePoints).map((v) => [drawParticle, v]); + } +} \ No newline at end of file diff --git a/scripts/classes/Structure.js b/scripts/classes/Structure.js new file mode 100644 index 0000000..ba35f29 --- /dev/null +++ b/scripts/classes/Structure.js @@ -0,0 +1,77 @@ +import { MinecraftDimensionTypes, world } from "@minecraft/server"; +import { Outliner } from "./Outliner"; + +export class Structure { + #structure; + #options = { + isPlaced: false, + dimensionId: MinecraftDimensionTypes.overworld, + location: { x: 0, y: 0, z: 0 }, + rotation: 0, + mirror: false + }; + + constructor(structureName) { + this.name = structureName; + this.#structure = world.structureManager.get(structureName); + this.#options = this.loadOptions(); + } + + loadOptions() { + try { + return JSON.parse(world.getDynamicProperty(`structOptions:${this.name}`)); + } catch (e) { + world.setDynamicProperty(`structOptions:${this.name}`, JSON.stringify(this.#options)); + } + return this.#options; + } + + updateOptions() { + world.setDynamicProperty(`structOptions:${this.name}`, JSON.stringify(this.#options)); + } + + getName() { + return this.name; + } + + getStructure() { + return this.#structure; + } + + getOutlineLimits() { + if (!this.#options.isPlaced) + throw new Error(`[StrucTool] Structure '${this.name}' is not placed.`); + return { + min: { + x: this.#options.location.x, + y: this.#options.location.y, + z: this.#options.location.z + }, + max: { + x: this.#options.location.x + this.#structure.size.x, + y: this.#options.location.y + this.#structure.size.y, + z: this.#options.location.z + this.#structure.size.z + } + } + } + + place(dimensionId, location) { + this.#options = { + isPlaced: true, + dimensionId, + location: { x: Math.floor(location.x), y: Math.floor(location.y), z: Math.floor(location.z) }, + }; + this.updateOptions(); + this.outliner = new Outliner(dimensionId, this.getOutlineLimits().min, this.getOutlineLimits().max); + this.outliner.startDraw(); + } + + remove() { + if (!this.#options.isPlaced) + throw new Error(`[StrucTool] Structure '${this.name}' is not placed.`); + this.#options.isPlaced = false; + this.updateOptions(); + this.outliner.stopDraw(); + delete this.outliner; + } +} \ No newline at end of file diff --git a/scripts/classes/StructureCollection.js b/scripts/classes/StructureCollection.js new file mode 100644 index 0000000..cbd578a --- /dev/null +++ b/scripts/classes/StructureCollection.js @@ -0,0 +1,39 @@ +import { world } from '@minecraft/server'; +import { Structure } from './Structure'; + +class StructureCollection { + #structures; + + constructor() { + this.#structures = {}; + } + + add(name) { + const struct = world.structureManager.get(name); + this.#structures[name] = new Structure(name, struct); + } + + get(name) { + const structure = this.#structures[name]; + if (!structure) { + throw new Error(`Structure ${name} not found.`); + } + return structure; + } + + remove(name) { + const struct = this.get(name); + struct.remove(); + delete this.#structures[name]; + } + + place(name, dimensionId, location) { + const struct = this.get(name); + if (!struct) { + throw new Error(`Structure ${name} not found.`); + } + struct.place(dimensionId, location); + } +} + +export const structureCollection = new StructureCollection(); \ No newline at end of file diff --git a/scripts/commands/struct.js b/scripts/commands/struct.js new file mode 100644 index 0000000..3ce064b --- /dev/null +++ b/scripts/commands/struct.js @@ -0,0 +1,42 @@ +import { Command } from '../lib/canopy/CanopyExtension'; +import { extension } from '../config'; +import { structureCollection } from '../classes/StructureCollection'; + +const structCmd = new Command({ + name: 'struct', + description: { text: 'Manages current StrucTool structures.' }, + usage: 'struct', + callback: structCommand, + args: [ + { type: 'string', name: 'option' }, + { type: 'string', name: 'name' } + ] +}); +extension.addCommand(structCmd); + +function structCommand(sender, args) { + const { option, name, structure } = args; + switch (option) { + case 'add': + addStructure(sender, name, structure); + break; + case 'remove': + removeStructure(sender, name); + break; + case 'place': + structureCollection.place(name, sender.dimension.id, sender.location); + break; + default: + structCmd.sendUsage(sender); + } +} + +function addStructure(sender, name, filename) { + structureCollection.add(name, filename); + sender.sendMessage({ text: `Added structure '${name}'` }); +} + +function removeStructure(sender, name) { + structureCollection.remove(name); + sender.sendMessage({ text: `Removed structure '${name}'` }); +} \ No newline at end of file diff --git a/scripts/config.js b/scripts/config.js new file mode 100644 index 0000000..39efd76 --- /dev/null +++ b/scripts/config.js @@ -0,0 +1,8 @@ +import { CanopyExtension } from './lib/canopy/CanopyExtension'; + +export const extension = new CanopyExtension({ + author: 'ForestOfLight', + name: 'StrucTool', + description: 'Survival building extension for §l§aCanopy§r!', + version: '1.0.0' +}); \ No newline at end of file diff --git a/scripts/lib/Vector.js b/scripts/lib/Vector.js new file mode 100644 index 0000000..85fe960 --- /dev/null +++ b/scripts/lib/Vector.js @@ -0,0 +1,69 @@ +/** + * Part of ItemStack Database by @gameza_src + * Unknown author + */ +const isVec3Symbol = Symbol("isVec3"); +export function Vector(x = 0, y = 0, z = 0) { + if (new.target) { + this.x = Number(x); + this.y = Number(y); + this.z = Number(z); + } else {return { x: Number(x), y: Number(y), z: Number(z), __proto__: Vector.prototype };} +} +Vector.magnitude = function magnitude(vec) { return Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z); } +Vector.normalize = function normalize(vec) { const l = Vector.magnitude(vec); return { x: vec.x / l, y: vec.y / l, z: vec.z / l, __proto__: Vector.prototype }; } +Vector.cross = function crossProduct(a, b) { return { x: a.y * b.z - a.z * b.y, y: a.x * b.z - a.z * b.x, z: a.x * b.y - a.y * b.x, __proto__: Vector.prototype }; } +Vector.dot = function dot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +Vector.angleBetween = function angleBetween(a, b) { return Math.acos(Vector.dot(a, b) / (Vector.magnitude(a) * Vector.magnitude(b))); } +Vector.subtract = function subtract(a, b) { return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z, __proto__: Vector.prototype } }; +Vector.add = function add(a, b) { return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z, __proto__: Vector.prototype } }; +Vector.multiply = function multiply(vec, num) { + if (typeof num == "number") return { x: vec.x * num, y: vec.y * num, z: vec.z * num, __proto__: Vector.prototype }; + return { x: vec.x * num.x, y: vec.y * num.y, z: vec.z * num.z, __proto__: Vector.prototype }; +} +Vector.isVec3 = function isVec3(vec) { return vec[isVec3Symbol] === true; } +Vector.floor = function floor(vec) { return { x: Math.floor(vec.x), y: Math.floor(vec.y), z: Math.floor(vec.z), __proto__: Vector.prototype }; } +Vector.projection = function projection(a, b) { return Vector.multiply(b, Vector.dot(a, b) / ((b.x * b.x + b.y * b.y + b.z * b.z) ** 2)); } +Vector.rejection = function rejection(a, b) { return Vector.subtract(a, Vector.projection(a, b)); } +Vector.reflect = function reflect(v, n) { return Vector.subtract(v, Vector.multiply(n, 2 * Vector.dot(v, n))); } +Vector.lerp = function lerp(a, b, t) { return Vector.multiply(a, 1 - t).add(Vector.multiply(b, t)); } +Vector.distance = function distance(a, b) { return Vector.magnitude(Vector.subtract(a, b)); } +Vector.from = function from(object) { + if (Vector.isVec3(object)) return object; + if (Array.isArray(object)) return new Vector(object[0], object[1], object[2]); + const { x = 0, y = 0, z = 0 } = object ?? {}; + return { x: Number(x), y: Number(y), z: Number(z), __proto__: Vector.prototype }; +} +Vector.sort = function sort(vec1, vec2) { + const [x1, x2] = vec1.x < vec2.x ? [vec1.x, vec2.x] : [vec2.x, vec1.x]; + const [y1, y2] = vec1.y < vec2.y ? [vec1.y, vec2.y] : [vec2.y, vec1.y]; + const [z1, z2] = vec1.z < vec2.z ? [vec1.z, vec2.z] : [vec2.z, vec1.z]; + return [{ x: x1, y: y1, z: z1, __proto__: Vector.prototype }, { x: x2, y: y2, z: z2, __proto__: Vector.prototype }]; +} +Vector.up = { x: 0, y: 1, z: 0, __proto__: Vector.prototype }; +Vector.down = { x: 0, y: -1, z: 0, __proto__: Vector.prototype }; +Vector.right = { x: 1, y: 0, z: 0, __proto__: Vector.prototype }; +Vector.left = { x: -1, y: 0, z: 0, __proto__: Vector.prototype }; +Vector.forward = { x: 0, y: 0, z: 1, __proto__: Vector.prototype }; +Vector.backward = { x: 0, y: 0, z: -1, __proto__: Vector.prototype }; +Vector.zero = { x: 0, y: 0, z: 0, __proto__: Vector.prototype }; +Vector.prototype = { + distance(vec) { return Vector.distance(this, vec); }, + lerp(vec, t) { return Vector.lerp(this, vec, t); }, + projection(vec) { return Vector.projection(this, vec); }, + reflect(vec) { return Vector.reflect(this, vec); }, + rejection(vec) { return Vector.rejection(this, vec); }, + cross(vec) { return Vector.cross(this, vec); }, + dot(vec) { return Vector.dot(this, vec); }, + floor() { return Vector.floor(this); }, + add(vec) { return Vector.add(this, vec); }, + subtract(vec) { return Vector.subtract(this, vec); }, + multiply(num) { return Vector.multiply(this, num); }, + get length() { return Vector.magnitude(this); }, + get normalized() { return Vector.normalize(this); }, + x: 0, + y: 0, + z: 0, + [isVec3Symbol]: true, + toString() { return `<${this.x}, ${this.y}, ${this.z}>`; } +} \ No newline at end of file diff --git a/scripts/main.js b/scripts/main.js index 9646470..f03bc75 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -1,97 +1,4 @@ -import { CanopyExtension, Command, Rule } from './lib/canopy/CanopyExtension'; -import { world } from '@minecraft/server'; +// Rules -/** - * Create a new CanopyExtension instance to define your extension. - */ -const extension = new CanopyExtension({ - author: 'YourName', - name: 'ExampleExtension', - description: 'Example extension for §l§aCanopy§r!', - version: '1.0.0' -}); - -// -------------------------------------------- - -/** - * Adding a new Rule: - */ -const exampleRule = new Rule({ - identifier: 'exampleRule', // The name of the rule - description: { text: 'An example rule that prints a message in chat when you hit a button.' }, // Shows up in the help command. Must be a RawMessage type (translatable!). - // Optional: - contingentRules: [], // Rules that will be enabled when this rule is enabled - independentRules: [], // Rules that will be disabled when this rule is enabled - onEnableCallback: () => world.afterEvents.buttonPush.subscribe(onButtonPush), // Function to run when the rule is enabled (also runs when the extension is loaded, if the rule is already enabled) - onDisableCallback: () => world.afterEvents.buttonPush.unsubscribe(onButtonPush) // Function to run when the rule is disabled -}); -extension.addRule(exampleRule); - -// use the rule to control your code flow -function onButtonPush(event) { - if (!exampleRule.getValue()) - return; - if (event.source === undefined) // Always check for undefined entities and players. Simulated players always show up as undefined in events. - return; - event.source.sendMessage('§aYou pushed a button!'); -} - -// -------------------------------------------- - -/** - * Making a second new rule which we can use to enable the example command: - * (This is not required to make a new command, but it is helpful to allow admins to disable your commands.) - */ -const commandExampleRule = new Rule({ - identifier: 'commandExample', - description: { text: 'Enables the example command.' } // Shows up in the help command. RawMessage type. -}); -extension.addRule(commandExampleRule); - -/** - * Adding a new Command: - */ -const exampleCommand = new Command({ - name: 'example', // The name of the command - description: { text: 'An example command that prints your message in chat.' }, // Shows up in the help command. RawMessage type. - usage: 'example [message]', // The usage of the command that shows up in the help command & when used incorrectly - callback: exampleCommandCallback, // The function to run when the command is executed - // Optional: - args: [ - { type: 'string|number', name: 'message' } // The arguments that the command takes. 'string|number' means it can be either a string or a number - ], - contingentRules: ['commandExample'], // Rules that must be true for the command to be enabled - adminOnly: false, // Whether the command can only be run by admins (users with the 'CanopyAdmin' tag) - helpEntries: [ // Additional help entries that show up in the help command - { usage: `example`, description: { text: `Run the example command with the default message.` } } // Description is a RawMessage type. - ], - helpHidden: false // Whether the command should be hidden from the help command. -}); -extension.addCommand(exampleCommand); - -/** - * Adding a command alias: - * This is essentially just adding a new command that runs the same function as the original command and hiding its help. - */ -const exampleCommandAlias = new Command({ - name: 'ex', - description: { text: 'An alias for the example command.' }, - usage: 'ex [message]', - callback: exampleCommandCallback, - args: [ - { type: 'string|number', name: 'message' } - ], - contingentRules: ['commandExample'], - helpHidden: true -}); -extension.addCommand(exampleCommandAlias); - -// now you can define the function that will be called when the command is executed -function exampleCommandCallback(sender, args) { - let { message } = args; - if (message === null) - message = 'Hello, world!'; - if (!isNaN(parseFloat(message)) && isFinite(message)) - message = message.toString(); - sender.sendMessage(`§aYou ran the example command with the message: §7${message}`); -} +// Commands +import './commands/struct.js'; diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 0000000..6cf2187 --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,13 @@ +import { FormCancelationReason } from '@minecraft/server-ui'; + +export async function forceShow(player, form, timeout = Infinity) { + const startTick = system.currentTick; + while ((system.currentTick - startTick) < timeout) { + const response = await form.show(player); + if (startTick + 1 === system.currentTick && response.cancelationReason === FormCancelationReason.UserBusy) + player.sendMessage({ translate: 'commands.canopy.menu.busy' }); + if (response.cancelationReason !== FormCancelationReason.UserBusy) + return response; + } + throw new Error({ translate: 'commands.canopy.menu.timeout', with: [String(timeout)] }); +}; \ No newline at end of file diff --git a/structures/Everything.mcstructure b/structures/Everything.mcstructure new file mode 100644 index 0000000..5eb8d95 Binary files /dev/null and b/structures/Everything.mcstructure differ