Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb2b27d7c | ||
|
|
1e82825f8f | ||
|
|
db8931784e | ||
|
|
8ded29cbcc | ||
|
|
b28e4d0610 | ||
|
|
fd73ac55ec |
@@ -14,6 +14,8 @@ on:
|
|||||||
- 'keep-inventory-addon/**'
|
- 'keep-inventory-addon/**'
|
||||||
- 'postal-service-addon/**'
|
- 'postal-service-addon/**'
|
||||||
- 'camping-supplies-addon/**'
|
- 'camping-supplies-addon/**'
|
||||||
|
- 'family-goals-addon/**'
|
||||||
|
- 'redstone-link-addon/**'
|
||||||
- 'docker-compose.yml'
|
- 'docker-compose.yml'
|
||||||
- 'scripts/**'
|
- 'scripts/**'
|
||||||
|
|
||||||
@@ -31,7 +33,12 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
APP_DIR="$HOME/minecraft-multiworld"
|
APP_DIR="$HOME/minecraft-multiworld"
|
||||||
PATHS="addon/ lobby-addon/ hub-return-addon/ village-evolution-addon/ monkey-addon/ private-chest-addon/ home-sign-addon/ keep-inventory-addon/ postal-service-addon/ camping-supplies-addon/ docker-compose.yml"
|
# Anything mounted in docker-compose.yml but missing here is simply
|
||||||
|
# never refreshed by CI. family-goals-addon and redstone-link-addon
|
||||||
|
# are listed because docker-compose.yml mounts them; omitting either
|
||||||
|
# while checking out docker-compose.yml would leave a mount pointing
|
||||||
|
# at a directory this job never delivers.
|
||||||
|
PATHS="addon/ lobby-addon/ hub-return-addon/ village-evolution-addon/ monkey-addon/ private-chest-addon/ home-sign-addon/ keep-inventory-addon/ postal-service-addon/ camping-supplies-addon/ family-goals-addon/ redstone-link-addon/ docker-compose.yml"
|
||||||
|
|
||||||
# First run: clone. Subsequent: pull.
|
# First run: clone. Subsequent: pull.
|
||||||
if [ ! -d "$APP_DIR/.git" ]; then
|
if [ ! -d "$APP_DIR/.git" ]; then
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"explosion_resistance": 1.0
|
"explosion_resistance": 1.0
|
||||||
},
|
},
|
||||||
"minecraft:map_color": "#547A4E",
|
"minecraft:map_color": "#547A4E",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "tent_canvas",
|
"texture": "tent_canvas",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ services:
|
|||||||
- ./dynamite-addon/dynamite_RP:/data/resource_packs/dynamite_RP
|
- ./dynamite-addon/dynamite_RP:/data/resource_packs/dynamite_RP
|
||||||
- ./tow-boat-addon/tow_boat_BP:/data/behavior_packs/tow_boat_BP
|
- ./tow-boat-addon/tow_boat_BP:/data/behavior_packs/tow_boat_BP
|
||||||
- ./tow-boat-addon/tow_boat_RP:/data/resource_packs/tow_boat_RP
|
- ./tow-boat-addon/tow_boat_RP:/data/resource_packs/tow_boat_RP
|
||||||
|
- ./redstone-link-addon/redstone_link_BP:/data/behavior_packs/redstone_link_BP
|
||||||
|
- ./redstone-link-addon/redstone_link_RP:/data/resource_packs/redstone_link_RP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Cap each Bedrock service so a runaway/hung server can't OOM-kill its
|
# Cap each Bedrock service so a runaway/hung server can't OOM-kill its
|
||||||
# neighbours. Host has 8 GB; 4 × 1500 MB leaves headroom for the OS and
|
# neighbours. Host has 8 GB; 4 × 1500 MB leaves headroom for the OS and
|
||||||
@@ -81,6 +83,7 @@ services:
|
|||||||
- ./trees-features-addon/trees_features_RP:/data/resource_packs/trees_features_RP
|
- ./trees-features-addon/trees_features_RP:/data/resource_packs/trees_features_RP
|
||||||
- ./hemp-addon/hemp_BP:/data/behavior_packs/hemp_BP
|
- ./hemp-addon/hemp_BP:/data/behavior_packs/hemp_BP
|
||||||
- ./hemp-addon/hemp_RP:/data/resource_packs/hemp_RP
|
- ./hemp-addon/hemp_RP:/data/resource_packs/hemp_RP
|
||||||
|
- ./family-goals-addon/family_goals_BP:/data/behavior_packs/family_goals_BP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 1500m
|
mem_limit: 1500m
|
||||||
memswap_limit: 2500m
|
memswap_limit: 2500m
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"header": {
|
||||||
|
"name": "Family Goals",
|
||||||
|
"description": "Shared goals, family bank, loot share, pity timer, and PvP-off for the family survival world",
|
||||||
|
"uuid": "f1a2b3c4-d5e6-4789-90ab-cdef12345001",
|
||||||
|
"version": [1, 0, 6],
|
||||||
|
"min_engine_version": [1, 21, 0]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"type": "script",
|
||||||
|
"language": "javascript",
|
||||||
|
"uuid": "f1a2b3c4-d5e6-4789-90ab-cdef12345002",
|
||||||
|
"version": [1, 0, 6],
|
||||||
|
"entry": "scripts/main.js"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"module_name": "@minecraft/server",
|
||||||
|
"version": "1.17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_name": "@minecraft/server-ui",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_name": "@minecraft/server-admin",
|
||||||
|
"version": "1.0.0-beta"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
// Family Goals Addon
|
||||||
|
//
|
||||||
|
// Lives in jamie's world (the shared family survival world). Provides:
|
||||||
|
// • PvP-off (siblings can't hurt each other)
|
||||||
|
// • Family roster ( !family setup adds online players )
|
||||||
|
// • Family Bank ( !family bank registers a vanilla chest as shared )
|
||||||
|
// • Five shared goals with per-player contribution tracking
|
||||||
|
// • Loot share within 32 blocks (sibling helpers get a kill bonus)
|
||||||
|
// • Pity timer (idle siblings get an encouragement bundle every 60 min)
|
||||||
|
//
|
||||||
|
// All persistent state lives in world dynamic properties so it survives
|
||||||
|
// container restarts. No resource pack — vanilla items + titleraw + actionforms.
|
||||||
|
|
||||||
|
import { world, system, ItemStack } from "@minecraft/server";
|
||||||
|
import { ActionFormData } from "@minecraft/server-ui";
|
||||||
|
// We don't actually use @minecraft/server-admin, but importing from it flips
|
||||||
|
// the beta-API gate for this pack so world.beforeEvents.chatSend resolves.
|
||||||
|
// If we ever drop this import, fall back to afterEvents.chatSend (no cancel).
|
||||||
|
import { variables } from "@minecraft/server-admin";
|
||||||
|
void variables; // silence unused warning
|
||||||
|
|
||||||
|
// ─── Tunables ──────────────────────────────────────────────────
|
||||||
|
const FAMILY_PROP = "family_roster_v1"; // { members: [<playerId>, ...] }
|
||||||
|
const GOALS_PROP = "family_goals_v1"; // see initGoals() for shape
|
||||||
|
const BANK_PROP = "family_bank_v1"; // { x, y, z, dim } | null
|
||||||
|
const PROGRESS_PROP = "family_last_progress_at"; // per-player dyn prop, Date.now()
|
||||||
|
|
||||||
|
const VANILLA_CHEST = "minecraft:chest";
|
||||||
|
const LOOT_SHARE_RADIUS = 32;
|
||||||
|
const PITY_THRESHOLD_MS = 60 * 60 * 1000; // 60 min of no progress
|
||||||
|
const PITY_TICK_INTERVAL = 6000; // poll every 5 min (20 tps)
|
||||||
|
const NIGHTS_TICK_INTERVAL = 200; // every 10s
|
||||||
|
|
||||||
|
const GOAL_DEFS = [
|
||||||
|
{ id: "logs", label: "Logs Chopped", target: 5000 },
|
||||||
|
{ id: "diamonds", label: "Diamonds Mined", target: 50 },
|
||||||
|
{ id: "mobs", label: "Monsters Defeated", target: 500 },
|
||||||
|
{ id: "crops", label: "Crops Harvested", target: 1000 },
|
||||||
|
{ id: "nights", label: "Nights Survived", target: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOG_IDS = new Set([
|
||||||
|
"minecraft:oak_log", "minecraft:spruce_log", "minecraft:birch_log",
|
||||||
|
"minecraft:jungle_log", "minecraft:acacia_log", "minecraft:dark_oak_log",
|
||||||
|
"minecraft:cherry_log", "minecraft:mangrove_log",
|
||||||
|
"minecraft:stripped_oak_log", "minecraft:stripped_spruce_log",
|
||||||
|
"minecraft:stripped_birch_log", "minecraft:stripped_jungle_log",
|
||||||
|
"minecraft:stripped_acacia_log", "minecraft:stripped_dark_oak_log",
|
||||||
|
"minecraft:stripped_cherry_log", "minecraft:stripped_mangrove_log",
|
||||||
|
]);
|
||||||
|
const DIAMOND_IDS = new Set([
|
||||||
|
"minecraft:diamond_ore", "minecraft:deepslate_diamond_ore",
|
||||||
|
]);
|
||||||
|
// Block id → "growth"-like state value at full maturity
|
||||||
|
const CROP_AGES = {
|
||||||
|
"minecraft:wheat": 7,
|
||||||
|
"minecraft:carrots": 7,
|
||||||
|
"minecraft:potatoes": 7,
|
||||||
|
"minecraft:beetroot": 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── In-memory mirror of dynamic properties ────────────────────
|
||||||
|
let family = { members: [] };
|
||||||
|
let goals = null;
|
||||||
|
let bank = null;
|
||||||
|
let lastDay = -1; // tracks day-rollover for the "nights survived" goal
|
||||||
|
|
||||||
|
// ─── Persistence ───────────────────────────────────────────────
|
||||||
|
function loadState() {
|
||||||
|
try {
|
||||||
|
const f = world.getDynamicProperty(FAMILY_PROP);
|
||||||
|
if (typeof f === "string") family = JSON.parse(f);
|
||||||
|
} catch (_) { family = { members: [] }; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const g = world.getDynamicProperty(GOALS_PROP);
|
||||||
|
if (typeof g === "string") goals = JSON.parse(g);
|
||||||
|
} catch (_) { goals = null; }
|
||||||
|
if (!goals || !goals.goals) goals = initGoals();
|
||||||
|
// Backfill any newly added goal definitions on upgrade
|
||||||
|
for (const def of GOAL_DEFS) {
|
||||||
|
if (!goals.goals[def.id]) {
|
||||||
|
goals.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const b = world.getDynamicProperty(BANK_PROP);
|
||||||
|
if (typeof b === "string") bank = JSON.parse(b);
|
||||||
|
} catch (_) { bank = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGoals() {
|
||||||
|
const g = { goals: {} };
|
||||||
|
for (const def of GOAL_DEFS) {
|
||||||
|
g.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} };
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFamily() {
|
||||||
|
try { world.setDynamicProperty(FAMILY_PROP, JSON.stringify(family)); } catch (_) {}
|
||||||
|
}
|
||||||
|
function saveGoals() {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(goals);
|
||||||
|
world.setDynamicProperty(GOALS_PROP, json);
|
||||||
|
const verify = world.getDynamicProperty(GOALS_PROP);
|
||||||
|
console.warn(`[Family] saveGoals: wrote ${json.length} chars, readback typeof=${typeof verify} len=${typeof verify === "string" ? verify.length : "n/a"}`);
|
||||||
|
} catch (e) { console.warn(`[Family] saveGoals FAILED: ${e}`); }
|
||||||
|
}
|
||||||
|
function saveBank() {
|
||||||
|
try { world.setDynamicProperty(BANK_PROP, bank ? JSON.stringify(bank) : undefined); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFamily(player) {
|
||||||
|
if (!player || !family.members) return false;
|
||||||
|
return family.members.includes(player.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Goal mutation ─────────────────────────────────────────────
|
||||||
|
function incrGoal(goalId, player, n = 1) {
|
||||||
|
if (!isFamily(player)) return;
|
||||||
|
const g = goals.goals[goalId];
|
||||||
|
if (!g || g.completed) return;
|
||||||
|
g.current += n;
|
||||||
|
g.contributions[player.id] = (g.contributions[player.id] || 0) + n;
|
||||||
|
try { player.setDynamicProperty(PROGRESS_PROP, Date.now()); } catch (_) {}
|
||||||
|
|
||||||
|
if (g.current >= g.target) {
|
||||||
|
g.completed = true;
|
||||||
|
g.current = g.target;
|
||||||
|
fireGoalReward(goalId);
|
||||||
|
}
|
||||||
|
saveGoals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireGoalReward(goalId) {
|
||||||
|
const def = GOAL_DEFS.find(d => d.id === goalId);
|
||||||
|
const label = def ? def.label : goalId;
|
||||||
|
for (const p of world.getAllPlayers()) {
|
||||||
|
try {
|
||||||
|
p.onScreenDisplay.setTitle(`§6✦ Family Goal Complete!`, {
|
||||||
|
subtitle: `§e${label}`,
|
||||||
|
fadeInDuration: 10,
|
||||||
|
stayDuration: 80,
|
||||||
|
fadeOutDuration: 20,
|
||||||
|
});
|
||||||
|
p.dimension.runCommand(`summon firework_rocket ${p.location.x} ${p.location.y + 2} ${p.location.z}`);
|
||||||
|
// v1 trophy: a named gold ingot. Easy to tell apart in inventory.
|
||||||
|
p.runCommand(`give @s gold_ingot 1`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
world.sendMessage(`§6[Family] §eGoal complete: §f${label}§e! §7Trophy gold awarded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Family roster command ─────────────────────────────────────
|
||||||
|
function familySetup(player) {
|
||||||
|
const online = world.getAllPlayers();
|
||||||
|
const before = new Set(family.members);
|
||||||
|
let added = 0;
|
||||||
|
for (const p of online) {
|
||||||
|
if (!before.has(p.id)) {
|
||||||
|
family.members.push(p.id);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveFamily();
|
||||||
|
|
||||||
|
const names = online.map(p => p.name).join(", ");
|
||||||
|
if (added === 0) {
|
||||||
|
player.sendMessage(`§7[Family] §fAll currently-online players are already in the roster (${family.members.length} members).`);
|
||||||
|
} else {
|
||||||
|
player.sendMessage(`§a[Family] §fAdded ${added} new member(s). Roster (${family.members.length}): §e${names}§f.`);
|
||||||
|
world.sendMessage(`§a[Family] §fRoster updated by §e${player.name}§f.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function familyReset(player) {
|
||||||
|
family = { members: [] };
|
||||||
|
saveFamily();
|
||||||
|
player.sendMessage("§c[Family] §fRoster cleared. Run §e!family setup§f again to repopulate.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Family Bank command ───────────────────────────────────────
|
||||||
|
function familyBank(player) {
|
||||||
|
if (!isFamily(player)) {
|
||||||
|
player.sendMessage("§c[Family] §7Only family members can register the bank — run §e!family setup§7 first while everyone's online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let view;
|
||||||
|
try {
|
||||||
|
view = player.getBlockFromViewDirection({ maxDistance: 6 });
|
||||||
|
} catch (e) {
|
||||||
|
player.sendMessage(`§c[Family] §7Couldn't look at a block: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = view?.block;
|
||||||
|
if (!target || target.typeId !== VANILLA_CHEST) {
|
||||||
|
player.sendMessage("§c[Family] §7Look at a vanilla chest within 6 blocks, then re-run §e!family bank§7.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bank = {
|
||||||
|
x: target.location.x,
|
||||||
|
y: target.location.y,
|
||||||
|
z: target.location.z,
|
||||||
|
dim: target.dimension.id,
|
||||||
|
};
|
||||||
|
saveBank();
|
||||||
|
player.sendMessage(`§a[Family] §fBank registered at §e${bank.x}, ${bank.y}, ${bank.z}§f. All family members can now open it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bank gating (interact + break) ────────────────────────────
|
||||||
|
function isBankAt(loc, dimId) {
|
||||||
|
if (!bank) return false;
|
||||||
|
return Math.floor(loc.x) === bank.x
|
||||||
|
&& Math.floor(loc.y) === bank.y
|
||||||
|
&& Math.floor(loc.z) === bank.z
|
||||||
|
&& bank.dim === dimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
|
||||||
|
const block = event.block;
|
||||||
|
if (block.typeId !== VANILLA_CHEST) return;
|
||||||
|
if (!isBankAt(block.location, block.dimension.id)) return;
|
||||||
|
if (isFamily(event.player)) return;
|
||||||
|
event.cancel = true;
|
||||||
|
const playerRef = event.player;
|
||||||
|
system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can open this chest."));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Family] beforeEvents.playerInteractWithBlock unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||||
|
const block = event.block;
|
||||||
|
if (block.typeId !== VANILLA_CHEST) return;
|
||||||
|
if (!isBankAt(block.location, block.dimension.id)) return;
|
||||||
|
|
||||||
|
if (isFamily(event.player)) {
|
||||||
|
// Family member breaking the bank → un-register so it stops being protected
|
||||||
|
bank = null;
|
||||||
|
saveBank();
|
||||||
|
const p = event.player;
|
||||||
|
system.run(() => p.sendMessage("§7[Family Bank] §fBank chest broken — un-registered. Place a new chest and run §e!family bank§f to re-register."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.cancel = true;
|
||||||
|
const playerRef = event.player;
|
||||||
|
system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can break this chest."));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Family] beforeEvents.playerBreakBlock unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PvP off (heal damage between siblings) ────────────────────
|
||||||
|
// Bedrock has no server.properties pvp toggle — we hook the after-event
|
||||||
|
// and immediately re-heal. Net visible effect: zero damage between players.
|
||||||
|
world.afterEvents.entityHurt.subscribe((event) => {
|
||||||
|
const source = event.damageSource?.damagingEntity;
|
||||||
|
const victim = event.hurtEntity;
|
||||||
|
if (!source || !victim) return;
|
||||||
|
if (source.typeId !== "minecraft:player" || victim.typeId !== "minecraft:player") return;
|
||||||
|
try {
|
||||||
|
const health = victim.getComponent("minecraft:health");
|
||||||
|
if (!health) return;
|
||||||
|
const restored = Math.min(health.effectiveMax, health.currentValue + event.damage);
|
||||||
|
health.setCurrentValue(restored);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Goal triggers ─────────────────────────────────────────────
|
||||||
|
world.afterEvents.playerBreakBlock.subscribe((event) => {
|
||||||
|
const player = event.player;
|
||||||
|
if (!isFamily(player)) return;
|
||||||
|
const id = event.brokenBlockPermutation?.type?.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (LOG_IDS.has(id)) {
|
||||||
|
incrGoal("logs", player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DIAMOND_IDS.has(id)) {
|
||||||
|
incrGoal("diamonds", player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id in CROP_AGES) {
|
||||||
|
// Only fully grown crops count
|
||||||
|
let states = null;
|
||||||
|
try { states = event.brokenBlockPermutation.getAllStates(); } catch (_) {}
|
||||||
|
const age = states && (states["growth"] ?? states["age"]);
|
||||||
|
if (typeof age === "number" && age >= CROP_AGES[id]) {
|
||||||
|
incrGoal("crops", player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
world.afterEvents.entityDie.subscribe((event) => {
|
||||||
|
const dead = event.deadEntity;
|
||||||
|
if (!dead || dead.typeId === "minecraft:player") return;
|
||||||
|
const source = event.damageSource?.damagingEntity;
|
||||||
|
if (!source || source.typeId !== "minecraft:player") return;
|
||||||
|
if (!isFamily(source)) return;
|
||||||
|
|
||||||
|
// Goal: monsters only
|
||||||
|
let isMonster = false;
|
||||||
|
try {
|
||||||
|
const fam = dead.getComponent("minecraft:type_family");
|
||||||
|
if (fam && typeof fam.hasTypeFamily === "function" && fam.hasTypeFamily("monster")) {
|
||||||
|
isMonster = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (isMonster) incrGoal("mobs", source);
|
||||||
|
|
||||||
|
// Loot share: every family member in 32 blocks (other than killer) gets cooked beef.
|
||||||
|
// Sharing helpers, not real drop mirroring — keeps the drop pipeline untouched.
|
||||||
|
try {
|
||||||
|
const k = source.location;
|
||||||
|
const r2 = LOOT_SHARE_RADIUS * LOOT_SHARE_RADIUS;
|
||||||
|
for (const other of world.getAllPlayers()) {
|
||||||
|
if (other.id === source.id) continue;
|
||||||
|
if (!isFamily(other)) continue;
|
||||||
|
if (other.dimension.id !== source.dimension.id) continue;
|
||||||
|
const dx = other.location.x - k.x;
|
||||||
|
const dy = other.location.y - k.y;
|
||||||
|
const dz = other.location.z - k.z;
|
||||||
|
if (dx*dx + dy*dy + dz*dz <= r2) {
|
||||||
|
other.runCommand("give @s cooked_beef 2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Nights survived ───────────────────────────────────────────
|
||||||
|
system.runInterval(() => {
|
||||||
|
let day;
|
||||||
|
try { day = world.getDay(); } catch (_) { return; }
|
||||||
|
if (typeof day !== "number") return;
|
||||||
|
if (lastDay === -1) { lastDay = day; return; }
|
||||||
|
if (day > lastDay) {
|
||||||
|
const diff = day - lastDay;
|
||||||
|
lastDay = day;
|
||||||
|
for (const p of world.getAllPlayers()) {
|
||||||
|
if (isFamily(p)) incrGoal("nights", p, diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, NIGHTS_TICK_INTERVAL);
|
||||||
|
|
||||||
|
// ─── Pity timer ────────────────────────────────────────────────
|
||||||
|
system.runInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const p of world.getAllPlayers()) {
|
||||||
|
if (!isFamily(p)) continue;
|
||||||
|
|
||||||
|
let last;
|
||||||
|
try { last = p.getDynamicProperty(PROGRESS_PROP); } catch (_) { last = undefined; }
|
||||||
|
if (typeof last !== "number") {
|
||||||
|
// First time we've seen this family member contribute (or first session) — initialize, don't pity yet
|
||||||
|
try { p.setDynamicProperty(PROGRESS_PROP, now); } catch (_) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - last < PITY_THRESHOLD_MS) continue;
|
||||||
|
|
||||||
|
// Drop encouragement bundle directly into inventory
|
||||||
|
try {
|
||||||
|
p.runCommand("give @s diamond 1");
|
||||||
|
p.runCommand("give @s cooked_beef 16");
|
||||||
|
p.runCommand("give @s iron_pickaxe 1");
|
||||||
|
p.sendMessage("§6[Family] §fA little boost to keep you going! §7(family pity bundle)");
|
||||||
|
p.setDynamicProperty(PROGRESS_PROP, now); // reset so we don't spam every 5 min
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}, PITY_TICK_INTERVAL);
|
||||||
|
|
||||||
|
// ─── Goal display ──────────────────────────────────────────────
|
||||||
|
function makeBar(pct) {
|
||||||
|
const filled = Math.max(0, Math.min(10, Math.floor(pct / 10)));
|
||||||
|
return "§a" + "▰".repeat(filled) + "§8" + "▰".repeat(10 - filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showGoals(player, perPlayerMode) {
|
||||||
|
const lines = [];
|
||||||
|
for (const def of GOAL_DEFS) {
|
||||||
|
const g = goals.goals[def.id];
|
||||||
|
if (!g) continue;
|
||||||
|
const cur = Math.min(g.current, g.target);
|
||||||
|
const pct = Math.floor((cur / g.target) * 100);
|
||||||
|
const bar = makeBar(pct);
|
||||||
|
const status = g.completed ? " §a✔" : "";
|
||||||
|
|
||||||
|
if (perPlayerMode) {
|
||||||
|
const my = g.contributions[player.id] || 0;
|
||||||
|
const myPct = cur > 0 ? Math.floor((my / cur) * 100) : 0;
|
||||||
|
lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}\n§7 You: §f${my}§7 (${myPct}% of progress)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length) lines.push("§7No goals configured.");
|
||||||
|
|
||||||
|
const form = new ActionFormData()
|
||||||
|
.title(perPlayerMode ? "My Goal Contributions" : "Family Goals")
|
||||||
|
.body(lines.join("\n\n"))
|
||||||
|
.button("§7OK");
|
||||||
|
try { await form.show(player); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Family menu (entered via scriptevent from hub-return compass) ─────
|
||||||
|
function showRoster(player) {
|
||||||
|
if (family.members.length === 0) {
|
||||||
|
player.sendMessage("§7[Family] §fNo roster set yet — open §e🏆 Family§f from your compass and pick §eAdd me to the family§f.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = world.getAllPlayers()
|
||||||
|
.filter(p => family.members.includes(p.id))
|
||||||
|
.map(p => p.name);
|
||||||
|
player.sendMessage(`§a[Family] §fRoster: §e${family.members.length}§f members. Online: §e${names.join(", ") || "(none)"}§f.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFamilyMenu(player) {
|
||||||
|
const inFamily = isFamily(player);
|
||||||
|
const form = new ActionFormData()
|
||||||
|
.title("🏆 Family")
|
||||||
|
.body(`§7Roster: §f${family.members.length}§7 members${inFamily ? " §a(you're in)" : " §c(you're not)"}\n§7Bank: ${bank ? `§f${bank.x}, ${bank.y}, ${bank.z}` : "§cnot set"}`);
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
form.button("🎯 Family Goals"); actions.push("goals");
|
||||||
|
form.button("📝 My Contributions"); actions.push("mygoals");
|
||||||
|
form.button("👥 Show Roster"); actions.push("roster");
|
||||||
|
form.button("➕ Add me / Add online players"); actions.push("setup");
|
||||||
|
form.button("🏦 Register Family Bank (look at chest)"); actions.push("bank");
|
||||||
|
form.button("♻ Reset Roster (danger)"); actions.push("reset");
|
||||||
|
form.button("§7Cancel"); actions.push("cancel");
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try { res = await form.show(player); } catch (_) { return; }
|
||||||
|
if (res.canceled || res.selection === undefined) return;
|
||||||
|
const a = actions[res.selection];
|
||||||
|
|
||||||
|
if (a === "goals") return showGoals(player, false);
|
||||||
|
if (a === "mygoals") return showGoals(player, true);
|
||||||
|
if (a === "roster") return showRoster(player);
|
||||||
|
if (a === "setup") return familySetup(player);
|
||||||
|
if (a === "bank") return familyBank(player);
|
||||||
|
if (a === "reset") return familyReset(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptEventReceive is the cross-pack dispatch surface. Hub-return's compass
|
||||||
|
// menu fires `scriptevent family:menu` to enter our UI. Direct ids are also
|
||||||
|
// handled so an opped player can run e.g. `/scriptevent family:setup`.
|
||||||
|
try {
|
||||||
|
system.afterEvents.scriptEventReceive.subscribe((event) => {
|
||||||
|
const id = event.id;
|
||||||
|
if (!id || !id.startsWith("family:")) return;
|
||||||
|
const player = event.sourceEntity;
|
||||||
|
if (!player || player.typeId !== "minecraft:player") return;
|
||||||
|
switch (id) {
|
||||||
|
case "family:menu": system.run(() => openFamilyMenu(player)); break;
|
||||||
|
case "family:goals": system.run(() => showGoals(player, false)); break;
|
||||||
|
case "family:mygoals": system.run(() => showGoals(player, true)); break;
|
||||||
|
case "family:roster": showRoster(player); break;
|
||||||
|
case "family:setup": familySetup(player); break;
|
||||||
|
case "family:bank": familyBank(player); break;
|
||||||
|
case "family:reset": familyReset(player); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Family] scriptEventReceive unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boot ──────────────────────────────────────────────────────
|
||||||
|
system.run(() => {
|
||||||
|
loadState();
|
||||||
|
saveGoals(); // persist any backfilled goal defs from upgrade
|
||||||
|
|
||||||
|
world.sendMessage("§6[Family] §fLoaded. Right-click your §dHub Compass§f to find the §6🏆 Family§f menu.");
|
||||||
|
});
|
||||||
@@ -379,32 +379,35 @@ world.beforeEvents.playerInteractWithEntity.subscribe((event) => {
|
|||||||
if (!deal) return;
|
if (!deal) return;
|
||||||
event.cancel = true; // suppress the vanilla trade window for this interaction
|
event.cancel = true; // suppress the vanilla trade window for this interaction
|
||||||
const player = event.player;
|
const player = event.player;
|
||||||
|
const held = stack.amount;
|
||||||
system.run(() => {
|
system.run(() => {
|
||||||
// Count what the player has of this item across the inventory
|
if (held < deal.perTrade) {
|
||||||
const inv = getInv(player);
|
player.sendMessage(`§7[Trader] §fBring me at least §a${deal.perTrade}§f of those and I'll pay in emeralds.`);
|
||||||
if (!inv) return;
|
|
||||||
let have = 0;
|
|
||||||
for (let i = 0; i < inv.size; i++) {
|
|
||||||
const it = inv.getItem(i);
|
|
||||||
if (it && it.typeId === stack.typeId) have += it.amount;
|
|
||||||
}
|
|
||||||
if (have < deal.perTrade) {
|
|
||||||
player.sendMessage(`§7[Trader] Brings me at least §f${deal.perTrade}§7 of those and I'll pay you in emeralds.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trades = Math.floor(have / deal.perTrade);
|
const inv = getInv(player);
|
||||||
let consumed = 0;
|
if (!inv) return;
|
||||||
for (let n = 0; n < trades * deal.perTrade; n++) {
|
// Trade scope is the held stack only — never walk the rest of the inventory
|
||||||
if (!consumeOneOfType(player, stack.typeId)) break;
|
const sel = player.selectedSlotIndex;
|
||||||
consumed++;
|
const cur = inv.getItem(sel);
|
||||||
|
if (!cur || cur.typeId !== stack.typeId) {
|
||||||
|
// Player swapped item out between the click and this run() — abort safely
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const actualTrades = Math.floor(consumed / deal.perTrade);
|
const trades = Math.floor(held / deal.perTrade);
|
||||||
if (actualTrades <= 0) return;
|
const consumed = trades * deal.perTrade;
|
||||||
giveItem(player, "minecraft:emerald", actualTrades * deal.emeralds);
|
const remaining = held - consumed;
|
||||||
|
if (remaining > 0) {
|
||||||
|
cur.amount = remaining;
|
||||||
|
inv.setItem(sel, cur);
|
||||||
|
} else {
|
||||||
|
inv.setItem(sel, undefined);
|
||||||
|
}
|
||||||
|
giveItem(player, "minecraft:emerald", trades * deal.emeralds);
|
||||||
const loc = target.location;
|
const loc = target.location;
|
||||||
try { target.dimension.runCommand(`particle minecraft:villager_happy ${loc.x} ${loc.y + 1.5} ${loc.z}`); } catch (_) {}
|
try { target.dimension.runCommand(`particle minecraft:villager_happy ${loc.x} ${loc.y + 1.5} ${loc.z}`); } catch (_) {}
|
||||||
try { target.dimension.runCommand(`playsound mob.villager.yes @a ${loc.x} ${loc.y} ${loc.z} 0.8 1.1`); } catch (_) {}
|
try { target.dimension.runCommand(`playsound mob.villager.yes @a ${loc.x} ${loc.y} ${loc.z} 0.8 1.1`); } catch (_) {}
|
||||||
player.sendMessage(`§a[Trader] §fTraded §e${actualTrades * deal.perTrade}§f for §a${actualTrades * deal.emeralds} emerald${actualTrades * deal.emeralds === 1 ? "" : "s"}§f.`);
|
player.sendMessage(`§a[Trader] §fTraded §e${consumed}§f for §a${trades * deal.emeralds} emerald${trades * deal.emeralds === 1 ? "" : "s"}§f.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -715,9 +718,9 @@ function saveSeededChests(set) {
|
|||||||
world.afterEvents.playerInteractWithBlock.subscribe((event) => {
|
world.afterEvents.playerInteractWithBlock.subscribe((event) => {
|
||||||
const block = event.block;
|
const block = event.block;
|
||||||
if (!block || !CHEST_TYPES.has(block.typeId)) return;
|
if (!block || !CHEST_TYPES.has(block.typeId)) return;
|
||||||
// Only on the open hand (not while holding an item, to avoid placing-into events firing twice)
|
// Run on every chest interaction; the per-chest seeded set prevents repeats.
|
||||||
const stack = event.itemStack;
|
// Earlier `if (stack) return` was over-aggressive — players almost always
|
||||||
if (stack) return;
|
// hold an item when opening chests, so it suppressed nearly all opens.
|
||||||
const key = chestKey(block);
|
const key = chestKey(block);
|
||||||
const seeded = loadSeededChests();
|
const seeded = loadSeededChests();
|
||||||
if (seeded.has(key)) return;
|
if (seeded.has(key)) return;
|
||||||
@@ -734,9 +737,76 @@ world.afterEvents.playerInteractWithBlock.subscribe((event) => {
|
|||||||
const slot = empties[rand(empties.length)];
|
const slot = empties[rand(empties.length)];
|
||||||
const count = 1 + rand(3); // 1-3 seeds
|
const count = 1 + rand(3); // 1-3 seeds
|
||||||
try { inv.setItem(slot, new ItemStack(SEEDS, count)); } catch (_) {}
|
try { inv.setItem(slot, new ItemStack(SEEDS, count)); } catch (_) {}
|
||||||
// No chat ping — let the player discover the seeds organically.
|
try { event.player.sendMessage("§a[Hemp] §7You spot some §dhemp seeds§7 tucked inside the chest."); } catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Wild hemp scatter: bridge until worldgen patches show up ---
|
||||||
|
// Bedrock features only fire on FRESH chunk generation. Pre-existing
|
||||||
|
// chunks (where the player has already been) won't have patches even
|
||||||
|
// after we ship the feature_rule. This periodic scatter seeds mature
|
||||||
|
// hemp_crop on suitable grass blocks in the player's vicinity so the
|
||||||
|
// mechanic is discoverable in legacy worlds. Cap one patch per minute
|
||||||
|
// per player to avoid flooding.
|
||||||
|
const SCATTER_INTERVAL_TICKS = 1200; // 60 s
|
||||||
|
const SCATTER_CHANCE_PER_TICK = 0.4; // 40% per minute → ~1 patch every 2.5 min
|
||||||
|
const SCATTER_RADIUS = 32;
|
||||||
|
const SCATTER_PATCH_SIZE = [2, 4]; // 2-4 plants per patch
|
||||||
|
function tryScatterHempNearPlayer(player) {
|
||||||
|
const dim = player.dimension;
|
||||||
|
if (dim.id !== "minecraft:overworld") return;
|
||||||
|
const px = Math.floor(player.location.x);
|
||||||
|
const py = Math.floor(player.location.y);
|
||||||
|
const pz = Math.floor(player.location.z);
|
||||||
|
// Sample up to 12 candidate locations; pick the first viable grass block
|
||||||
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
const dx = rand(SCATTER_RADIUS * 2 + 1) - SCATTER_RADIUS;
|
||||||
|
const dz = rand(SCATTER_RADIUS * 2 + 1) - SCATTER_RADIUS;
|
||||||
|
// Search vertically: top-down within ±6 of player y for the surface
|
||||||
|
let surface = null;
|
||||||
|
for (let dy = 6; dy >= -6; dy--) {
|
||||||
|
let b;
|
||||||
|
try { b = dim.getBlock({ x: px + dx, y: py + dy, z: pz + dz }); } catch (_) { continue; }
|
||||||
|
if (!b) continue;
|
||||||
|
if (b.typeId === "minecraft:grass_block") {
|
||||||
|
// Check above is air (and skylit-ish — not a cave with grass_path glitches)
|
||||||
|
let above;
|
||||||
|
try { above = dim.getBlock({ x: px + dx, y: py + dy + 1, z: pz + dz }); } catch (_) { continue; }
|
||||||
|
if (above && above.isAir) { surface = { x: px + dx, y: py + dy + 1, z: pz + dz }; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!surface) continue;
|
||||||
|
// Place 2-4 mature hemp_crop in a small cluster around surface
|
||||||
|
const count = SCATTER_PATCH_SIZE[0] + rand(SCATTER_PATCH_SIZE[1] - SCATTER_PATCH_SIZE[0] + 1);
|
||||||
|
let placed = 0;
|
||||||
|
for (let i = 0; i < count * 3 && placed < count; i++) {
|
||||||
|
const ox = rand(5) - 2;
|
||||||
|
const oz = rand(5) - 2;
|
||||||
|
const tx = surface.x + ox, ty = surface.y, tz = surface.z + oz;
|
||||||
|
let target, ground;
|
||||||
|
try {
|
||||||
|
target = dim.getBlock({ x: tx, y: ty, z: tz });
|
||||||
|
ground = dim.getBlock({ x: tx, y: ty - 1, z: tz });
|
||||||
|
} catch (_) { continue; }
|
||||||
|
if (!target || !ground) continue;
|
||||||
|
if (!target.isAir) continue;
|
||||||
|
if (ground.typeId !== "minecraft:grass_block" && ground.typeId !== "minecraft:dirt") continue;
|
||||||
|
try {
|
||||||
|
const perm = BlockPermutation.resolve(CROP, { [AGE]: 4, [TOP]: false });
|
||||||
|
target.setPermutation(perm);
|
||||||
|
placed++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return; // one patch per tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
system.runInterval(() => {
|
||||||
|
const players = world.getAllPlayers();
|
||||||
|
if (players.length === 0) return;
|
||||||
|
const player = players[rand(players.length)];
|
||||||
|
if (!chance(SCATTER_CHANCE_PER_TICK)) return;
|
||||||
|
tryScatterHempNearPlayer(player);
|
||||||
|
}, SCATTER_INTERVAL_TICKS);
|
||||||
|
|
||||||
system.run(() => {
|
system.run(() => {
|
||||||
world.sendMessage("§a[Hemp] §7Hemp pack loaded.");
|
world.sendMessage("§a[Hemp] §7Hemp pack loaded.");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"explosion_resistance": 200.0
|
"explosion_resistance": 200.0
|
||||||
},
|
},
|
||||||
"minecraft:map_color": "#C0703A",
|
"minecraft:map_color": "#C0703A",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "home_sign",
|
"texture": "home_sign",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"version": [
|
"version": [
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
2
|
5
|
||||||
],
|
],
|
||||||
"min_engine_version": [
|
"min_engine_version": [
|
||||||
1,
|
1,
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"version": [
|
"version": [
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
2
|
5
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"minecraft:light_emission": 10,
|
"minecraft:light_emission": 10,
|
||||||
"minecraft:map_color": "#5C6D74",
|
"minecraft:map_color": "#5C6D74",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "portal_frame",
|
"texture": "portal_frame",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"minecraft:light_emission": 10,
|
"minecraft:light_emission": 10,
|
||||||
"minecraft:map_color": "#2D9C2D",
|
"minecraft:map_color": "#2D9C2D",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "portal_jamie",
|
"texture": "portal_jamie",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"minecraft:light_emission": 10,
|
"minecraft:light_emission": 10,
|
||||||
"minecraft:map_color": "#9B59B6",
|
"minecraft:map_color": "#9B59B6",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "portal_lyla",
|
"texture": "portal_lyla",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"minecraft:light_emission": 10,
|
"minecraft:light_emission": 10,
|
||||||
"minecraft:map_color": "#4FC1E9",
|
"minecraft:map_color": "#4FC1E9",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "portal_mya",
|
"texture": "portal_mya",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Redstone Link
|
||||||
|
|
||||||
|
Wireless redstone. Vanilla dust needs a solid block under it and gets cut off by
|
||||||
|
terrain; this replaces the wire entirely with a transmitter/receiver pair bound
|
||||||
|
by a channel name.
|
||||||
|
|
||||||
|
| Block | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `silverlabs:redstone_link_tx` — Redstone Link Transmitter | Reads redstone power from any of its six neighbours. While powered, its channel is live. |
|
||||||
|
| `silverlabs:redstone_link_rx` — Redstone Link Receiver | While its channel is live, emits full-strength (15) power in every direction. |
|
||||||
|
|
||||||
|
Craft either from a redstone block + ender pearl (+ copper for the transmitter,
|
||||||
|
iron for the receiver). Both give 2.
|
||||||
|
|
||||||
|
**Setting a channel:** right-click a block and type a name, e.g. `front-door`.
|
||||||
|
Every transmitter and receiver sharing that name is linked — many-to-many, any
|
||||||
|
distance, through any terrain, in any direction. Newly placed blocks start on
|
||||||
|
channel `default`.
|
||||||
|
|
||||||
|
## How it works, and why
|
||||||
|
|
||||||
|
Bedrock custom blocks can *read* redstone power but cannot *emit* it — there is
|
||||||
|
no script API for that. So a live receiver is physically swapped for a vanilla
|
||||||
|
`minecraft:redstone_block` (strength 15, omnidirectional, needs no support) and
|
||||||
|
swapped back when the channel goes quiet.
|
||||||
|
|
||||||
|
That swap is why `scripts/registry.js` exists: while a receiver is powered the
|
||||||
|
block itself no longer says it is a receiver, so the positions are kept in a
|
||||||
|
world dynamic property. It survives container restarts, and a reconcile pass at
|
||||||
|
boot repairs anything left inconsistent by a crash.
|
||||||
|
|
||||||
|
Breaking a powered receiver is intercepted so it returns the receiver block
|
||||||
|
rather than a free redstone block.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
These are inherent to the approach, not bugs:
|
||||||
|
|
||||||
|
- **Both ends must be in loaded chunks.** A link whose receiver sits in unloaded
|
||||||
|
terrain simply does not fire. Cover it with a ticking area if it must always work.
|
||||||
|
- **~2 tick latency.** Fine for doors, lamps and farms; not for sub-tick clocks.
|
||||||
|
- **Binary, not graded.** Output is always 15, never a decayed signal.
|
||||||
|
- **Same dimension only.**
|
||||||
|
- **256 nodes per world.**
|
||||||
|
|
||||||
|
A transmitter ignores power coming from its own channel's receivers, so putting
|
||||||
|
a matched pair side by side will not latch itself on.
|
||||||
|
|
||||||
|
## Console / bridge control
|
||||||
|
|
||||||
|
Blocks placed by `/setblock`, `/fill`, or the mc-ai-bridge MCP build tools never
|
||||||
|
fire `playerPlaceBlock`, so they are not registered and stay inert. Register them
|
||||||
|
explicitly (`sourceEntity` is null over RCON/the bridge, so no player is needed):
|
||||||
|
|
||||||
|
```
|
||||||
|
/scriptevent rlink:register <x> <y> <z> <channel>
|
||||||
|
/scriptevent rlink:unregister <x> <y> <z>
|
||||||
|
/scriptevent rlink:status
|
||||||
|
```
|
||||||
|
|
||||||
|
`rlink:status` reports node counts and active channels — the quickest way to
|
||||||
|
confirm the pack is alive on a headless server.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Bind-mount both packs into the target service in `docker-compose.yml`, then pin
|
||||||
|
the pack UUIDs into that world's `world_behavior_packs.json` /
|
||||||
|
`world_resource_packs.json`. **The version arrays must match the manifest header
|
||||||
|
versions exactly** or the pack is silently dropped from the stack.
|
||||||
|
|
||||||
|
| Pack | UUID | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| BP header | `e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b001` | `[1, 0, 0]` |
|
||||||
|
| RP header | `e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004` | `[1, 0, 0]` |
|
||||||
|
|
||||||
|
Currently deployed to **lobby** (`Hub World`) only.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Each pack is zipped from INSIDE its own directory so that manifest.json sits
|
||||||
|
# at the archive root. An extra nesting level makes Minecraft reject the import.
|
||||||
|
|
||||||
|
# No -x exclude here, unlike addon/build.sh: these pack dirs contain no
|
||||||
|
# dotfiles, and dropping it keeps the script working with the 7-Zip `zip`
|
||||||
|
# shim on the Windows workstation as well as Info-ZIP on the CI runner.
|
||||||
|
|
||||||
|
echo "Packaging Behavior Pack..."
|
||||||
|
cd "$SCRIPT_DIR/redstone_link_BP"
|
||||||
|
zip -rq "$BUILD_DIR/redstone_link_BP.mcpack" .
|
||||||
|
|
||||||
|
echo "Packaging Resource Pack..."
|
||||||
|
cd "$SCRIPT_DIR/redstone_link_RP"
|
||||||
|
zip -rq "$BUILD_DIR/redstone_link_RP.mcpack" .
|
||||||
|
|
||||||
|
echo "Creating .mcaddon bundle..."
|
||||||
|
cd "$BUILD_DIR"
|
||||||
|
zip -rq "$BUILD_DIR/redstone_link.mcaddon" redstone_link_BP.mcpack redstone_link_RP.mcpack
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete!"
|
||||||
|
echo " Output: $BUILD_DIR/redstone_link.mcaddon"
|
||||||
|
echo ""
|
||||||
|
echo "To install:"
|
||||||
|
echo " - Double-click the .mcaddon file on Windows to auto-import"
|
||||||
|
echo " - Or copy packs to com.mojang/development_behavior_packs/ and development_resource_packs/"
|
||||||
|
echo ""
|
||||||
|
echo "Note: import BOTH packs. The behavior pack alone gives you working"
|
||||||
|
echo "logic with missing textures and untranslated block names."
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"format_version": "1.21.0",
|
||||||
|
"minecraft:block": {
|
||||||
|
"description": {
|
||||||
|
"identifier": "silverlabs:redstone_link_rx",
|
||||||
|
"menu_category": {
|
||||||
|
"category": "items",
|
||||||
|
"group": "itemGroup.name.redstone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"minecraft:destructible_by_mining": {
|
||||||
|
"seconds_to_destroy": 1.5
|
||||||
|
},
|
||||||
|
"minecraft:destructible_by_explosion": {
|
||||||
|
"explosion_resistance": 20.0
|
||||||
|
},
|
||||||
|
"minecraft:map_color": "#1A4C8C",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
|
"minecraft:material_instances": {
|
||||||
|
"*": {
|
||||||
|
"texture": "redstone_link_rx",
|
||||||
|
"render_method": "opaque"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"format_version": "1.21.0",
|
||||||
|
"minecraft:block": {
|
||||||
|
"description": {
|
||||||
|
"identifier": "silverlabs:redstone_link_tx",
|
||||||
|
"menu_category": {
|
||||||
|
"category": "items",
|
||||||
|
"group": "itemGroup.name.redstone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"minecraft:destructible_by_mining": {
|
||||||
|
"seconds_to_destroy": 1.5
|
||||||
|
},
|
||||||
|
"minecraft:destructible_by_explosion": {
|
||||||
|
"explosion_resistance": 20.0
|
||||||
|
},
|
||||||
|
"minecraft:map_color": "#8C1A1A",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
|
"minecraft:material_instances": {
|
||||||
|
"*": {
|
||||||
|
"texture": "redstone_link_tx",
|
||||||
|
"render_method": "opaque"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"header": {
|
||||||
|
"name": "Redstone Link",
|
||||||
|
"description": "Wireless redstone: pair a transmitter and a receiver by channel name, no wire in between",
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b001",
|
||||||
|
"version": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"min_engine_version": [
|
||||||
|
1,
|
||||||
|
21,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"type": "data",
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b002",
|
||||||
|
"version": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "script",
|
||||||
|
"language": "javascript",
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b003",
|
||||||
|
"version": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"entry": "scripts/main.js"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"module_name": "@minecraft/server",
|
||||||
|
"version": "1.17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_name": "@minecraft/server-ui",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004",
|
||||||
|
"version": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"format_version": "1.21.0",
|
||||||
|
"minecraft:recipe_shapeless": {
|
||||||
|
"description": {
|
||||||
|
"identifier": "silverlabs:redstone_link_rx_recipe"
|
||||||
|
},
|
||||||
|
"tags": ["crafting_table"],
|
||||||
|
"unlock": { "context": "AlwaysUnlocked" },
|
||||||
|
"ingredients": [
|
||||||
|
{ "item": "minecraft:redstone_block" },
|
||||||
|
{ "item": "minecraft:ender_pearl" },
|
||||||
|
{ "item": "minecraft:iron_ingot" }
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"item": "silverlabs:redstone_link_rx",
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"format_version": "1.21.0",
|
||||||
|
"minecraft:recipe_shapeless": {
|
||||||
|
"description": {
|
||||||
|
"identifier": "silverlabs:redstone_link_tx_recipe"
|
||||||
|
},
|
||||||
|
"tags": ["crafting_table"],
|
||||||
|
"unlock": { "context": "AlwaysUnlocked" },
|
||||||
|
"ingredients": [
|
||||||
|
{ "item": "minecraft:redstone_block" },
|
||||||
|
{ "item": "minecraft:ender_pearl" },
|
||||||
|
{ "item": "minecraft:copper_ingot" }
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"item": "silverlabs:redstone_link_tx",
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// Redstone Link Addon
|
||||||
|
//
|
||||||
|
// Wireless redstone for the SilverLABS multiworld. Place a transmitter next to
|
||||||
|
// any redstone source and a receiver anywhere else, give both the same channel
|
||||||
|
// name, and the receiver emits full-strength power whenever the transmitter is
|
||||||
|
// powered. No wire, no line of sight, no support block, any distance.
|
||||||
|
//
|
||||||
|
// Why a block swap: Bedrock custom blocks can READ redstone power but cannot
|
||||||
|
// EMIT it -- there is no script API for that. So a live receiver is physically
|
||||||
|
// replaced by a vanilla redstone block (strength 15, omnidirectional, needs no
|
||||||
|
// support) and swapped back when the channel goes quiet. registry.js remembers
|
||||||
|
// where the receivers are, because while powered the block no longer says so.
|
||||||
|
//
|
||||||
|
// Known limits, by design:
|
||||||
|
// * Both ends must be in loaded chunks. Out-of-range links simply do nothing.
|
||||||
|
// * ~2 tick latency. Not for sub-tick contraptions.
|
||||||
|
// * Binary, not graded: output is always 15.
|
||||||
|
|
||||||
|
import { world, system, ItemStack, BlockPermutation } from "@minecraft/server";
|
||||||
|
import { ModalFormData } from "@minecraft/server-ui";
|
||||||
|
import { isTransmitterPowered } from "./power.js";
|
||||||
|
import * as registry from "./registry.js";
|
||||||
|
|
||||||
|
const TX_ID = "silverlabs:redstone_link_tx";
|
||||||
|
const RX_ID = "silverlabs:redstone_link_rx";
|
||||||
|
const LIVE_ID = "minecraft:redstone_block";
|
||||||
|
|
||||||
|
const TICK_INTERVAL = 2;
|
||||||
|
const MAX_CHANNEL_LEN = 24;
|
||||||
|
const DEFAULT_CHANNEL = "default";
|
||||||
|
|
||||||
|
function roleOf(typeId) {
|
||||||
|
if (typeId === TX_ID) return "tx";
|
||||||
|
if (typeId === RX_ID) return "rx";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitiseChannel(raw) {
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
return raw.trim().toLowerCase().slice(0, MAX_CHANNEL_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockAt(node) {
|
||||||
|
// Returns undefined when the chunk is not loaded -- callers must treat that
|
||||||
|
// as "unknown", never as "gone", or we would delete live nodes.
|
||||||
|
let dim;
|
||||||
|
try { dim = world.getDimension(node.d); } catch (_) { return undefined; }
|
||||||
|
if (!dim) return undefined;
|
||||||
|
try { return dim.getBlock({ x: node.x, y: node.y, z: node.z }); } catch (_) { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBlockTo(block, typeId) {
|
||||||
|
try {
|
||||||
|
block.setPermutation(BlockPermutation.resolve(typeId));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] failed to set ${typeId}: ${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The link engine ------------------------------------------------------
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const nodes = registry.all();
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
// Receiver positions grouped by channel, so a transmitter can ignore power
|
||||||
|
// coming from its own channel's receivers (self-latch guard).
|
||||||
|
const rxKeysByChannel = new Map();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.r !== "rx" || !n.c) continue;
|
||||||
|
let set = rxKeysByChannel.get(n.c);
|
||||||
|
if (!set) { set = new Set(); rxKeysByChannel.set(n.c, set); }
|
||||||
|
set.add(registry.nodeKey(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = new Set();
|
||||||
|
const live = new Set();
|
||||||
|
|
||||||
|
// Pass 1 -- which channels are being driven?
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.r !== "tx") continue;
|
||||||
|
const b = blockAt(n);
|
||||||
|
if (b === undefined) continue; // chunk unloaded: unknown
|
||||||
|
if (b.typeId !== TX_ID) { stale.add(registry.nodeKey(n)); continue; }
|
||||||
|
if (!n.c || live.has(n.c)) continue; // unset, or already known live
|
||||||
|
if (isTransmitterPowered(b, rxKeysByChannel.get(n.c))) live.add(n.c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 -- make every receiver match its channel.
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.r !== "rx") continue;
|
||||||
|
const b = blockAt(n);
|
||||||
|
if (b === undefined) continue;
|
||||||
|
const isOn = b.typeId === LIVE_ID;
|
||||||
|
const isOff = b.typeId === RX_ID;
|
||||||
|
if (!isOn && !isOff) { stale.add(registry.nodeKey(n)); continue; }
|
||||||
|
const want = Boolean(n.c) && live.has(n.c);
|
||||||
|
if (want && !isOn) setBlockTo(b, LIVE_ID);
|
||||||
|
else if (!want && !isOff) setBlockTo(b, RX_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.size > 0) registry.removeKeys(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup pass. Anything that got out of sync while the world was unloaded --
|
||||||
|
* a receiver left as a redstone block after a crash, a node whose block was
|
||||||
|
* removed by an explosion -- is corrected or forgotten here.
|
||||||
|
*/
|
||||||
|
function reconcile() {
|
||||||
|
const nodes = registry.all();
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
const stale = new Set();
|
||||||
|
let restored = 0;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const b = blockAt(n);
|
||||||
|
if (b === undefined) continue;
|
||||||
|
if (n.r === "tx") {
|
||||||
|
if (b.typeId !== TX_ID) stale.add(registry.nodeKey(n));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b.typeId === LIVE_ID) {
|
||||||
|
// No transmitter has been evaluated yet, so nothing is live: park it off.
|
||||||
|
if (setBlockTo(b, RX_ID)) restored++;
|
||||||
|
} else if (b.typeId !== RX_ID) {
|
||||||
|
stale.add(registry.nodeKey(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dropped = registry.removeKeys(stale);
|
||||||
|
if (restored || dropped) {
|
||||||
|
console.warn(`[RedstoneLink] reconcile: ${restored} receiver(s) reset, ${dropped} stale node(s) dropped`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel UI -----------------------------------------------------------
|
||||||
|
|
||||||
|
async function openChannelForm(player, block, node) {
|
||||||
|
const role = roleOf(block.typeId);
|
||||||
|
const label = role === "tx" ? "Transmitter" : "Receiver";
|
||||||
|
const current = node?.c ?? "";
|
||||||
|
|
||||||
|
const form = new ModalFormData()
|
||||||
|
.title(`§6Redstone Link §7${label}`)
|
||||||
|
.textField(
|
||||||
|
"Channel name\n§7Any transmitter and receiver sharing a channel are linked.",
|
||||||
|
"e.g. front-door",
|
||||||
|
current
|
||||||
|
);
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try { res = await form.show(player); } catch (_) { return; }
|
||||||
|
if (res.canceled) return;
|
||||||
|
|
||||||
|
const channel = sanitiseChannel(res.formValues?.[0]);
|
||||||
|
if (!channel) {
|
||||||
|
player.sendMessage("§c[Redstone Link] §7Channel name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = registry.add(block.dimension.id, block.location, role, channel);
|
||||||
|
if (!saved) {
|
||||||
|
player.sendMessage(`§c[Redstone Link] §7Too many links in this world (max ${registry.MAX_NODES}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.sendMessage(`§a[Redstone Link] §7${label} set to channel §f${channel}§7.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Events ---------------------------------------------------------------
|
||||||
|
|
||||||
|
try {
|
||||||
|
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
|
||||||
|
const block = event.block;
|
||||||
|
if (!block) return;
|
||||||
|
const role = roleOf(block.typeId);
|
||||||
|
if (!role) return;
|
||||||
|
event.cancel = true;
|
||||||
|
const playerRef = event.player;
|
||||||
|
const dimId = block.dimension.id;
|
||||||
|
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
|
||||||
|
system.run(() => {
|
||||||
|
const node = registry.find(dimId, loc);
|
||||||
|
openChannelForm(playerRef, block, node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] beforeEvents.playerInteractWithBlock unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||||
|
const block = event.block;
|
||||||
|
if (!block) return;
|
||||||
|
const role = roleOf(block.typeId);
|
||||||
|
if (!role) return;
|
||||||
|
const added = registry.add(block.dimension.id, block.location, role, DEFAULT_CHANNEL);
|
||||||
|
if (!added) {
|
||||||
|
event.player?.sendMessage(
|
||||||
|
`§c[Redstone Link] §7Too many links in this world (max ${registry.MAX_NODES}). This one will not work.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.player?.sendMessage(
|
||||||
|
`§7[Redstone Link] On channel §f${DEFAULT_CHANNEL}§7. Interact to rename it.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] afterEvents.playerPlaceBlock unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||||
|
const block = event.block;
|
||||||
|
if (!block) return;
|
||||||
|
const dimId = block.dimension.id;
|
||||||
|
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
|
||||||
|
const node = registry.find(dimId, loc);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (block.typeId === LIVE_ID) {
|
||||||
|
// A live receiver. Left alone, vanilla would hand out a free redstone
|
||||||
|
// block, so intercept: drop the receiver item instead.
|
||||||
|
event.cancel = true;
|
||||||
|
const playerRef = event.player;
|
||||||
|
system.run(() => {
|
||||||
|
registry.remove(dimId, loc);
|
||||||
|
let dim;
|
||||||
|
try { dim = world.getDimension(dimId); } catch (_) { return; }
|
||||||
|
try { dim.getBlock(loc)?.setPermutation(BlockPermutation.resolve("minecraft:air")); } catch (_) {}
|
||||||
|
let creative = false;
|
||||||
|
try { creative = playerRef?.getGameMode?.() === "creative"; } catch (_) { creative = false; }
|
||||||
|
if (creative) return;
|
||||||
|
try {
|
||||||
|
dim.spawnItem(new ItemStack(RX_ID, 1), { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 });
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpowered node: vanilla break already returns the right item.
|
||||||
|
system.run(() => registry.remove(dimId, loc));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] beforeEvents.playerBreakBlock unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Console / bridge control --------------------------------------------
|
||||||
|
//
|
||||||
|
// Blocks placed by command rather than by hand -- /setblock, /fill, or the
|
||||||
|
// mc-ai-bridge MCP build tools -- never fire playerPlaceBlock, so they are not
|
||||||
|
// in the registry and stay inert. These script events give the console and the
|
||||||
|
// bridge a way to register them. sourceEntity is null when run via RCON or the
|
||||||
|
// bridge, so we never require a player here.
|
||||||
|
//
|
||||||
|
// /scriptevent rlink:register <x> <y> <z> <channel>
|
||||||
|
// /scriptevent rlink:unregister <x> <y> <z>
|
||||||
|
// /scriptevent rlink:status
|
||||||
|
|
||||||
|
function announce(text) {
|
||||||
|
try { world.getDimension("overworld").runCommand(`say ${text}`); } catch (_) {}
|
||||||
|
console.warn(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegister(args) {
|
||||||
|
if (args.length < 4) {
|
||||||
|
announce("[RedstoneLink] usage: rlink:register <x> <y> <z> <channel>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = Number(args[0]), y = Number(args[1]), z = Number(args[2]);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||||
|
announce("[RedstoneLink] register: coordinates must be numbers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = sanitiseChannel(args.slice(3).join(" "));
|
||||||
|
if (!channel) {
|
||||||
|
announce("[RedstoneLink] register: channel name required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = { x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) };
|
||||||
|
let block;
|
||||||
|
try { block = world.getDimension("overworld").getBlock(loc); } catch (_) { block = undefined; }
|
||||||
|
if (!block) {
|
||||||
|
announce(`[RedstoneLink] register: no loaded block at ${loc.x},${loc.y},${loc.z}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = roleOf(block.typeId);
|
||||||
|
if (!role) {
|
||||||
|
announce(`[RedstoneLink] register: ${block.typeId} is not a link block`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = registry.add("minecraft:overworld", loc, role, channel);
|
||||||
|
if (!saved) {
|
||||||
|
announce(`[RedstoneLink] register: registry full (max ${registry.MAX_NODES})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announce(`[RedstoneLink] registered ${role} at ${loc.x},${loc.y},${loc.z} on channel ${channel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnregister(args) {
|
||||||
|
if (args.length < 3) {
|
||||||
|
announce("[RedstoneLink] usage: rlink:unregister <x> <y> <z>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = {
|
||||||
|
x: Math.floor(Number(args[0])),
|
||||||
|
y: Math.floor(Number(args[1])),
|
||||||
|
z: Math.floor(Number(args[2])),
|
||||||
|
};
|
||||||
|
const gone = registry.remove("minecraft:overworld", loc);
|
||||||
|
announce(`[RedstoneLink] unregister ${loc.x},${loc.y},${loc.z}: ${gone ? "removed" : "not found"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatus() {
|
||||||
|
const nodes = registry.all();
|
||||||
|
const tx = nodes.filter((n) => n.r === "tx").length;
|
||||||
|
const rx = nodes.filter((n) => n.r === "rx").length;
|
||||||
|
const channels = [...new Set(nodes.map((n) => n.c).filter(Boolean))];
|
||||||
|
announce(`[RedstoneLink] alive: ${nodes.length} node(s), ${tx} tx, ${rx} rx, channels=[${channels.join(",")}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
system.afterEvents.scriptEventReceive.subscribe((event) => {
|
||||||
|
const id = event.id;
|
||||||
|
if (!id || !id.startsWith("rlink:")) return;
|
||||||
|
const args = (event.message || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
try {
|
||||||
|
switch (id) {
|
||||||
|
case "rlink:register": handleRegister(args); break;
|
||||||
|
case "rlink:unregister": handleUnregister(args); break;
|
||||||
|
case "rlink:status": handleStatus(); break;
|
||||||
|
default: announce(`[RedstoneLink] unknown command ${id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
announce(`[RedstoneLink] ${id} failed: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] scriptEventReceive unavailable: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boot -----------------------------------------------------------------
|
||||||
|
|
||||||
|
system.run(() => {
|
||||||
|
try { reconcile(); } catch (e) { console.warn(`[RedstoneLink] reconcile failed: ${e}`); }
|
||||||
|
try {
|
||||||
|
system.runInterval(() => {
|
||||||
|
try { tick(); } catch (e) { console.warn(`[RedstoneLink] tick failed: ${e}`); }
|
||||||
|
}, TICK_INTERVAL);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] could not start tick loop: ${e}`);
|
||||||
|
}
|
||||||
|
world.sendMessage("§c[Redstone Link] §7Wireless redstone loaded.");
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Redstone input detection for the transmitter.
|
||||||
|
//
|
||||||
|
// Mirrors the approach already proven in hemp-addon's sun lamp: try the script
|
||||||
|
// API's getRedstonePower() first, and fall back to inspecting the six
|
||||||
|
// neighbouring blocks when it is unavailable or reports nothing.
|
||||||
|
|
||||||
|
export const REDSTONE_FACES = [
|
||||||
|
[1, 0, 0], [-1, 0, 0],
|
||||||
|
[0, 1, 0], [0, -1, 0],
|
||||||
|
[0, 0, 1], [0, 0, -1],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isPowerSource(b) {
|
||||||
|
if (!b) return false;
|
||||||
|
const t = b.typeId;
|
||||||
|
if (t === "minecraft:redstone_block") return true;
|
||||||
|
if (t === "minecraft:lit_redstone_torch" || t === "minecraft:redstone_torch") {
|
||||||
|
try { return b.permutation.getState("toggle_bit") !== false; } catch (_) {}
|
||||||
|
return t === "minecraft:lit_redstone_torch";
|
||||||
|
}
|
||||||
|
if (t === "minecraft:powered_repeater") return true;
|
||||||
|
if (t === "minecraft:powered_comparator") return true;
|
||||||
|
if (t === "minecraft:lever") {
|
||||||
|
try { return b.permutation.getState("open_bit") === true; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
if (t.endsWith("_button")) {
|
||||||
|
try { return b.permutation.getState("button_pressed_bit") === true; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
if (t.endsWith("_pressure_plate")) {
|
||||||
|
try {
|
||||||
|
const r = b.permutation.getState("redstone_signal") ?? 0;
|
||||||
|
return r > 0;
|
||||||
|
} catch (_) { return false; }
|
||||||
|
}
|
||||||
|
if (t === "minecraft:redstone_wire") {
|
||||||
|
try {
|
||||||
|
const p = b.permutation.getState("redstone_signal") ?? 0;
|
||||||
|
return p > 0;
|
||||||
|
} catch (_) { return false; }
|
||||||
|
}
|
||||||
|
if (t === "minecraft:daylight_detector_inverted") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this transmitter receiving power?
|
||||||
|
*
|
||||||
|
* `ignoreKeys` holds the position keys of receivers on the SAME channel. A
|
||||||
|
* powered receiver is a real redstone block, so without this guard a
|
||||||
|
* transmitter sitting next to its own channel's receiver would latch itself on
|
||||||
|
* forever. We skip those positions, and when one is adjacent we also skip the
|
||||||
|
* getRedstonePower() fast path, because that call cannot exclude a position.
|
||||||
|
*/
|
||||||
|
export function isTransmitterPowered(block, ignoreKeys) {
|
||||||
|
const dim = block.dimension;
|
||||||
|
const dimId = dim.id;
|
||||||
|
const { x, y, z } = block.location;
|
||||||
|
|
||||||
|
let adjacentSameChannelReceiver = false;
|
||||||
|
if (ignoreKeys && ignoreKeys.size > 0) {
|
||||||
|
for (const [dx, dy, dz] of REDSTONE_FACES) {
|
||||||
|
if (ignoreKeys.has(`${dimId}|${x + dx}|${y + dy}|${z + dz}`)) {
|
||||||
|
adjacentSameChannelReceiver = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adjacentSameChannelReceiver) {
|
||||||
|
try {
|
||||||
|
const p = block.getRedstonePower();
|
||||||
|
if (typeof p === "number" && p > 0) return true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dx, dy, dz] of REDSTONE_FACES) {
|
||||||
|
const pos = { x: x + dx, y: y + dy, z: z + dz };
|
||||||
|
if (ignoreKeys && ignoreKeys.has(`${dimId}|${pos.x}|${pos.y}|${pos.z}`)) continue;
|
||||||
|
let nb;
|
||||||
|
try { nb = dim.getBlock(pos); } catch (_) { continue; }
|
||||||
|
if (isPowerSource(nb)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Persistent node registry for Redstone Link.
|
||||||
|
//
|
||||||
|
// A "node" is one placed transmitter or receiver. We have to remember them
|
||||||
|
// because a powered receiver is physically replaced by a vanilla redstone
|
||||||
|
// block (Bedrock custom blocks cannot emit redstone power), so the block
|
||||||
|
// itself stops being evidence that a receiver is there.
|
||||||
|
//
|
||||||
|
// State lives in a world dynamic property, the same approach family-goals and
|
||||||
|
// private-chest use, so it survives container restarts.
|
||||||
|
|
||||||
|
import { world } from "@minecraft/server";
|
||||||
|
|
||||||
|
const DP_KEY = "rlink:nodes";
|
||||||
|
|
||||||
|
// Bounded so the serialised registry stays well inside the dynamic-property
|
||||||
|
// size budget and the tick loop stays cheap.
|
||||||
|
export const MAX_NODES = 256;
|
||||||
|
|
||||||
|
// node shape: { d: dimensionId, x, y, z, r: "tx" | "rx", c: channel }
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
if (cache) return cache;
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = world.getDynamicProperty(DP_KEY);
|
||||||
|
} catch (_) {
|
||||||
|
raw = undefined;
|
||||||
|
}
|
||||||
|
if (typeof raw !== "string" || raw.length === 0) {
|
||||||
|
cache = [];
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
cache = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (_) {
|
||||||
|
console.warn("[RedstoneLink] registry was corrupt, starting empty");
|
||||||
|
cache = [];
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save() {
|
||||||
|
if (!cache) return;
|
||||||
|
try {
|
||||||
|
world.setDynamicProperty(DP_KEY, JSON.stringify(cache));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[RedstoneLink] registry save failed: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyOf(dimensionId, loc) {
|
||||||
|
return `${dimensionId}|${Math.floor(loc.x)}|${Math.floor(loc.y)}|${Math.floor(loc.z)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeKey(n) {
|
||||||
|
return `${n.d}|${n.x}|${n.y}|${n.z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function find(dimensionId, loc) {
|
||||||
|
const k = keyOf(dimensionId, loc);
|
||||||
|
return load().find((n) => nodeKey(n) === k) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the node, or null if the registry is full. */
|
||||||
|
export function add(dimensionId, loc, role, channel) {
|
||||||
|
const list = load();
|
||||||
|
const k = keyOf(dimensionId, loc);
|
||||||
|
const existing = list.find((n) => nodeKey(n) === k);
|
||||||
|
if (existing) {
|
||||||
|
existing.r = role;
|
||||||
|
existing.c = channel;
|
||||||
|
save();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
if (list.length >= MAX_NODES) return null;
|
||||||
|
const node = {
|
||||||
|
d: dimensionId,
|
||||||
|
x: Math.floor(loc.x),
|
||||||
|
y: Math.floor(loc.y),
|
||||||
|
z: Math.floor(loc.z),
|
||||||
|
r: role,
|
||||||
|
c: channel,
|
||||||
|
};
|
||||||
|
list.push(node);
|
||||||
|
save();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remove(dimensionId, loc) {
|
||||||
|
const list = load();
|
||||||
|
const k = keyOf(dimensionId, loc);
|
||||||
|
const i = list.findIndex((n) => nodeKey(n) === k);
|
||||||
|
if (i < 0) return false;
|
||||||
|
list.splice(i, 1);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk removal by node key; one save for the whole batch. */
|
||||||
|
export function removeKeys(keys) {
|
||||||
|
if (!keys || keys.size === 0) return 0;
|
||||||
|
const list = load();
|
||||||
|
let removed = 0;
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
if (keys.has(nodeKey(list[i]))) {
|
||||||
|
list.splice(i, 1);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed > 0) save();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function all() {
|
||||||
|
return load();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"format_version": [1, 1, 0],
|
||||||
|
"silverlabs:redstone_link_tx": { "sound": "stone" },
|
||||||
|
"silverlabs:redstone_link_rx": { "sound": "stone" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"header": {
|
||||||
|
"name": "Redstone Link Resources",
|
||||||
|
"description": "Textures and lang for the silverlabs:redstone_link_tx / _rx blocks",
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004",
|
||||||
|
"version": [1, 0, 1],
|
||||||
|
"min_engine_version": [1, 21, 0]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"type": "resources",
|
||||||
|
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b005",
|
||||||
|
"version": [1, 0, 0]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
tile.silverlabs:redstone_link_tx.name=Redstone Link Transmitter
|
||||||
|
tile.silverlabs:redstone_link_rx.name=Redstone Link Receiver
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 291 B |
Binary file not shown.
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"resource_pack_name": "redstone_link_RP",
|
||||||
|
"texture_name": "atlas.terrain",
|
||||||
|
"padding": 8,
|
||||||
|
"num_mip_levels": 4,
|
||||||
|
"texture_data": {
|
||||||
|
"redstone_link_tx": {
|
||||||
|
"textures": "textures/blocks/redstone_link_tx"
|
||||||
|
},
|
||||||
|
"redstone_link_rx": {
|
||||||
|
"textures": "textures/blocks/redstone_link_rx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -397,12 +397,99 @@ def portal_field() -> Image.Image:
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ── Redstone Link transmitter / receiver ───────────────────
|
||||||
|
# Machine faces, not icons. Following the mailbox lesson: the motif has to
|
||||||
|
# reach the edges or a cube of it reads as a placeholder. Both share a gunmetal
|
||||||
|
# plate with corner rivets and a beveled frame; the transmitter carries redstone
|
||||||
|
# traces radiating OUT to all four edges, the receiver carries traces running IN
|
||||||
|
# to a collector dish. Same silhouette, mirrored behaviour, different accent.
|
||||||
|
|
||||||
|
LINK_PLATE = (62, 66, 74)
|
||||||
|
LINK_PLATE_HL = (92, 97, 107)
|
||||||
|
LINK_PLATE_SH = (38, 41, 47)
|
||||||
|
LINK_RIVET = (140, 146, 158)
|
||||||
|
|
||||||
|
TX_TRACE = (168, 30, 28)
|
||||||
|
TX_TRACE_HOT = REDSTONE_BRIGHT
|
||||||
|
TX_CORE = (255, 140, 110)
|
||||||
|
|
||||||
|
RX_TRACE = (34, 96, 150)
|
||||||
|
RX_TRACE_HOT = (92, 178, 240)
|
||||||
|
RX_CORE = (200, 240, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_plate() -> Image.Image:
|
||||||
|
"""Shared gunmetal base: beveled frame + corner rivets, fully opaque."""
|
||||||
|
img = Image.new("RGBA", (16, 16), LINK_PLATE)
|
||||||
|
# Bevel: lit top/left, shadowed bottom/right
|
||||||
|
rect(img, 0, 0, 15, 0, LINK_PLATE_HL)
|
||||||
|
rect(img, 0, 0, 0, 15, LINK_PLATE_HL)
|
||||||
|
rect(img, 0, 15, 15, 15, LINK_PLATE_SH)
|
||||||
|
rect(img, 15, 0, 15, 15, LINK_PLATE_SH)
|
||||||
|
# Corner rivets
|
||||||
|
for cx, cy in ((2, 2), (13, 2), (2, 13), (13, 13)):
|
||||||
|
px(img, cx, cy, LINK_RIVET)
|
||||||
|
px(img, cx, cy + 1, LINK_PLATE_SH)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def redstone_link_tx() -> Image.Image:
|
||||||
|
img = _link_plate()
|
||||||
|
# Traces running from the core out to all four edges: this is what makes a
|
||||||
|
# cube of it read as wiring rather than a centred sprite.
|
||||||
|
rect(img, 7, 0, 8, 15, TX_TRACE)
|
||||||
|
rect(img, 0, 7, 15, 8, TX_TRACE)
|
||||||
|
# Diagonal spurs toward the corners
|
||||||
|
for i in range(3, 7):
|
||||||
|
px(img, i, i, TX_TRACE)
|
||||||
|
px(img, 15 - i, i, TX_TRACE)
|
||||||
|
px(img, i, 15 - i, TX_TRACE)
|
||||||
|
px(img, 15 - i, 15 - i, TX_TRACE)
|
||||||
|
# Emitter core, hot centre
|
||||||
|
rect(img, 6, 6, 9, 9, TX_TRACE_HOT)
|
||||||
|
rect(img, 7, 7, 8, 8, TX_CORE)
|
||||||
|
px(img, 7, 7, (255, 235, 220))
|
||||||
|
# Trace highlight so the cross reads as raised
|
||||||
|
rect(img, 7, 1, 7, 5, TX_TRACE_HOT)
|
||||||
|
rect(img, 1, 7, 5, 7, TX_TRACE_HOT)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def redstone_link_rx() -> Image.Image:
|
||||||
|
img = _link_plate()
|
||||||
|
# Collector rings, broken at the axes so the in-running traces read clearly
|
||||||
|
for x in range(3, 13):
|
||||||
|
px(img, x, 3, RX_TRACE)
|
||||||
|
px(img, x, 12, RX_TRACE)
|
||||||
|
for y in range(3, 13):
|
||||||
|
px(img, 3, y, RX_TRACE)
|
||||||
|
px(img, 12, y, RX_TRACE)
|
||||||
|
for x in range(5, 11):
|
||||||
|
px(img, x, 5, RX_TRACE_HOT)
|
||||||
|
px(img, x, 10, RX_TRACE_HOT)
|
||||||
|
for y in range(5, 11):
|
||||||
|
px(img, 5, y, RX_TRACE_HOT)
|
||||||
|
px(img, 10, y, RX_TRACE_HOT)
|
||||||
|
# Feed lines from the edges into the dish
|
||||||
|
rect(img, 7, 0, 8, 2, RX_TRACE)
|
||||||
|
rect(img, 7, 13, 8, 15, RX_TRACE)
|
||||||
|
rect(img, 0, 7, 2, 8, RX_TRACE)
|
||||||
|
rect(img, 13, 7, 15, 8, RX_TRACE)
|
||||||
|
# Collector core
|
||||||
|
rect(img, 7, 7, 8, 8, RX_TRACE_HOT)
|
||||||
|
px(img, 7, 7, RX_CORE)
|
||||||
|
px(img, 8, 8, RX_CORE)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
save(smart_crafting_table(), "smart-crafting-addon/smart_crafting_RP/textures/blocks/smart_crafting_table.png")
|
save(smart_crafting_table(), "smart-crafting-addon/smart_crafting_RP/textures/blocks/smart_crafting_table.png")
|
||||||
save(post_office_block(), "postal-service-addon/postal_service_RP/textures/blocks/post_office.png")
|
save(post_office_block(), "postal-service-addon/postal_service_RP/textures/blocks/post_office.png")
|
||||||
save(mailbox_block(), "postal-service-addon/postal_service_RP/textures/blocks/mailbox.png")
|
save(mailbox_block(), "postal-service-addon/postal_service_RP/textures/blocks/mailbox.png")
|
||||||
save(tent_canvas(), "camping-supplies-addon/camping_supplies_RP/textures/blocks/tent_canvas.png")
|
save(tent_canvas(), "camping-supplies-addon/camping_supplies_RP/textures/blocks/tent_canvas.png")
|
||||||
save(portal_field(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_field.png")
|
save(portal_field(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_field.png")
|
||||||
|
save(redstone_link_tx(), "redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_tx.png")
|
||||||
|
save(redstone_link_rx(), "redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_rx.png")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"explosion_resistance": 20.0
|
"explosion_resistance": 20.0
|
||||||
},
|
},
|
||||||
"minecraft:map_color": "#D4AF37",
|
"minecraft:map_color": "#D4AF37",
|
||||||
|
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||||
"minecraft:material_instances": {
|
"minecraft:material_instances": {
|
||||||
"*": {
|
"*": {
|
||||||
"texture": "smart_crafting_table",
|
"texture": "smart_crafting_table",
|
||||||
|
|||||||
Reference in New Issue
Block a user