Add and remove structure with outline

This commit is contained in:
ForestOfLight
2025-03-10 23:16:42 -07:00
Unverified
parent f52f82b867
commit 67e3f6fecc
12 changed files with 369 additions and 153 deletions
+22 -51
View File
@@ -2,71 +2,42 @@
<a href="./pack_icon.png">
<img src="./pack_icon.png" alt="Canopy Extension Example Icon" width="100" height="100">
</a>
<p><b>Canopy Extension Example</b></p>
<p><b>StrucTool</b></p>
[![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)
</div>
---
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 <rule> [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 <name>`**
Adds a new structure.
![Help Page](./example_help.png)
**Usage: `./struct remove <name>`**
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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

+14 -6
View File
@@ -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"
}
}
+82
View File
@@ -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]);
}
}
+77
View File
@@ -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;
}
}
+39
View File
@@ -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();
+42
View File
@@ -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}'` });
}
+8
View File
@@ -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'
});
+69
View File
@@ -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}>`; }
}
+3 -96
View File
@@ -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';
+13
View File
@@ -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)] });
};
Binary file not shown.