Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c06dde06 | ||
|
|
0fb2b27d7c | ||
|
|
1e82825f8f | ||
|
|
db8931784e | ||
|
|
8ded29cbcc |
@@ -14,6 +14,8 @@ on:
|
||||
- 'keep-inventory-addon/**'
|
||||
- 'postal-service-addon/**'
|
||||
- 'camping-supplies-addon/**'
|
||||
- 'family-goals-addon/**'
|
||||
- 'redstone-link-addon/**'
|
||||
- 'docker-compose.yml'
|
||||
- 'scripts/**'
|
||||
|
||||
@@ -31,7 +33,12 @@ jobs:
|
||||
script: |
|
||||
set -e
|
||||
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.
|
||||
if [ ! -d "$APP_DIR/.git" ]; then
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"explosion_resistance": 1.0
|
||||
},
|
||||
"minecraft:map_color": "#547A4E",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "tent_canvas",
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
- ./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_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
|
||||
# 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
|
||||
@@ -81,6 +83,7 @@ services:
|
||||
- ./trees-features-addon/trees_features_RP:/data/resource_packs/trees_features_RP
|
||||
- ./hemp-addon/hemp_BP:/data/behavior_packs/hemp_BP
|
||||
- ./hemp-addon/hemp_RP:/data/resource_packs/hemp_RP
|
||||
- ./family-goals-addon/family_goals_BP:/data/behavior_packs/family_goals_BP
|
||||
restart: unless-stopped
|
||||
mem_limit: 1500m
|
||||
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.");
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
"explosion_resistance": 200.0
|
||||
},
|
||||
"minecraft:map_color": "#C0703A",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "home_sign",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"minecraft:light_emission": 10,
|
||||
"minecraft:map_color": "#5C6D74",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "portal_frame",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"minecraft:light_emission": 10,
|
||||
"minecraft:map_color": "#2D9C2D",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "portal_jamie",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"minecraft:light_emission": 10,
|
||||
"minecraft:map_color": "#9B59B6",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "portal_lyla",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"minecraft:light_emission": 10,
|
||||
"minecraft:map_color": "#4FC1E9",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"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
|
||||
|
||||
|
||||
# ── 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:
|
||||
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(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(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__":
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"explosion_resistance": 20.0
|
||||
},
|
||||
"minecraft:map_color": "#D4AF37",
|
||||
"minecraft:geometry": "minecraft:geometry.full_block",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "smart_crafting_table",
|
||||
|
||||
Reference in New Issue
Block a user