feat: add multi-world hub system with lobby portals and hub-return addon

Lobby addon detects players in portal zones at X: -15/0/15 and transfers
them to Jamie/Lyla/Mya survival worlds. Hub-return addon gives players a
recovery compass and chat commands (!hub, !lobby) to return to the lobby.

Includes docker-compose.yml for 4 Bedrock servers (lobby + 3 child worlds),
spark pet behavior/resource packs, and updated .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:02:56 +00:00
parent 4c68cb60bc
commit 389e053dc5
70 changed files with 3725 additions and 50 deletions

View File

@@ -0,0 +1,25 @@
{
"format_version": 2,
"header": {
"name": "Hub Return Transfer",
"description": "Transfers players back to lobby when they step on the return portal",
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
"version": [1, 0, 1],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "script",
"language": "javascript",
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
"version": [1, 0, 1],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "2.0.0"
}
]
}

View File

@@ -0,0 +1,224 @@
import { world, system, ItemStack } from "@minecraft/server";
const LOBBY_HOST = "10.0.0.247";
const LOBBY_PORT = 19132;
const COMPASS_ID = "minecraft:recovery_compass";
const PORTAL_PROP = "hub_portal_location";
const PORTAL_RADIUS = 3;
const TRANSFER_COOLDOWN = 5000; // 5 seconds
// Track recently transferred players
const recentTransfers = new Map();
function doTransfer(player) {
const now = Date.now();
if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) {
player.sendMessage("§c[Hub] §fPlease wait a moment before transferring again.");
return;
}
recentTransfers.set(player.name, now);
world.sendMessage(`§a${player.name} is returning to the Hub...`);
try {
player.runCommand(`transfer "${player.name}" ${LOBBY_HOST} ${LOBBY_PORT}`);
} catch (e) {
world.sendMessage(`§cTransfer failed: ${e.message}`);
}
}
// ─── A. Compass Distribution ────────────────────────────────────
function giveCompassIfMissing(player) {
const inventory = player.getComponent("minecraft:inventory");
if (!inventory) return;
const container = inventory.container;
for (let i = 0; i < container.size; i++) {
const item = container.getItem(i);
if (item && item.typeId === COMPASS_ID) return; // already has one
}
container.addItem(new ItemStack(COMPASS_ID, 1));
player.sendMessage("§b[Hub] §fYou received a §dHub Compass§f — use it to return to the lobby!");
}
world.afterEvents.playerSpawn.subscribe((event) => {
const player = event.player;
// Small delay to let inventory load
system.runTimeout(() => {
try {
giveCompassIfMissing(player);
} catch (e) {
// Silently ignore — player may have disconnected
}
}, 20);
});
// ─── B. Compass Use Transfer ────────────────────────────────────
world.afterEvents.itemUse.subscribe((event) => {
const player = event.source;
const item = event.itemStack;
if (item.typeId !== COMPASS_ID) return;
doTransfer(player);
});
// ─── C. Portal Zone Detection ───────────────────────────────────
system.runInterval(() => {
const portalJson = world.getDynamicProperty(PORTAL_PROP);
if (!portalJson) return;
let portal;
try {
portal = JSON.parse(portalJson);
} catch {
return;
}
const now = Date.now();
const players = world.getAllPlayers();
for (const player of players) {
if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) {
continue;
}
const pos = player.location;
const dx = Math.abs(pos.x - portal.x);
const dy = Math.abs(pos.y - portal.y);
const dz = Math.abs(pos.z - portal.z);
if (dx <= PORTAL_RADIUS && dy <= 3 && dz <= PORTAL_RADIUS) {
doTransfer(player);
}
}
}, 20);
// ─── D. Chat Commands ───────────────────────────────────────────
function handleChatCommand(player, msg) {
if (msg === "!compass") {
try {
giveCompassIfMissing(player);
player.sendMessage("§b[Hub] §fCompass check complete.");
} catch (e) {
player.sendMessage(`§c[Hub] §fCouldn't give compass: ${e.message}`);
}
return true;
}
if (msg === "!setportal") {
const pos = player.location;
const loc = {
x: Math.floor(pos.x),
y: Math.floor(pos.y),
z: Math.floor(pos.z),
};
world.setDynamicProperty(PORTAL_PROP, JSON.stringify(loc));
buildPortal(loc, player.dimension);
player.sendMessage(`§b[Hub] §fReturn portal set at §d${loc.x}, ${loc.y}, ${loc.z}§f!`);
return true;
}
if (msg === "!hub" || msg === "!lobby") {
doTransfer(player);
return true;
}
return false;
}
// Try beforeEvents.chatSend (pre-release in some BDS versions)
try {
world.beforeEvents.chatSend.subscribe((event) => {
const msg = event.message.trim().toLowerCase();
if (msg.startsWith("!")) {
event.cancel = true;
const player = event.sender;
system.run(() => handleChatCommand(player, msg));
}
});
} catch {
// chatSend not available — fall back to polling player chat via scriptEvent
}
// Fallback: listen for scriptevent commands (players run: /scriptevent hub:cmd <command>)
system.afterEvents.scriptEventReceive.subscribe((event) => {
if (event.id !== "hub:cmd") return;
const player = event.sourceEntity;
if (!player) return;
const msg = (event.message || "").trim().toLowerCase();
handleChatCommand(player, `!${msg}`);
});
// ─── E. Portal Auto-Build on First Load ─────────────────────────
function buildPortal(loc, dimension) {
const x = loc.x;
const y = loc.y;
const z = loc.z;
const commands = [];
// Clear space for portal (3 wide, 5 tall, 1 deep)
commands.push(`fill ${x - 1} ${y} ${z} ${x + 1} ${y + 4} ${z} air`);
// Base row: 3 obsidian
commands.push(`setblock ${x - 1} ${y} ${z} obsidian`);
commands.push(`setblock ${x} ${y} ${z} obsidian`);
commands.push(`setblock ${x + 1} ${y} ${z} obsidian`);
// Left column: 3 crying obsidian
commands.push(`setblock ${x - 1} ${y + 1} ${z} crying_obsidian`);
commands.push(`setblock ${x - 1} ${y + 2} ${z} crying_obsidian`);
commands.push(`setblock ${x - 1} ${y + 3} ${z} crying_obsidian`);
// Right column: 3 crying obsidian
commands.push(`setblock ${x + 1} ${y + 1} ${z} crying_obsidian`);
commands.push(`setblock ${x + 1} ${y + 2} ${z} crying_obsidian`);
commands.push(`setblock ${x + 1} ${y + 3} ${z} crying_obsidian`);
// Center: 3 purple stained glass
commands.push(`setblock ${x} ${y + 1} ${z} stained_glass ["color":"purple"]`);
commands.push(`setblock ${x} ${y + 2} ${z} stained_glass ["color":"purple"]`);
commands.push(`setblock ${x} ${y + 3} ${z} stained_glass ["color":"purple"]`);
// Top: 1 obsidian cap
commands.push(`setblock ${x} ${y + 4} ${z} obsidian`);
for (const cmd of commands) {
try {
dimension.runCommand(cmd);
} catch (e) {
// Block placement may fail if chunks not loaded — non-fatal
}
}
}
system.runTimeout(() => {
const existing = world.getDynamicProperty(PORTAL_PROP);
if (existing) return; // Portal location already set
// Get spawn point and offset by 5 blocks on X
const spawn = world.getDefaultSpawnLocation();
const loc = {
x: spawn.x + 5,
y: spawn.y,
z: spawn.z,
};
world.setDynamicProperty(PORTAL_PROP, JSON.stringify(loc));
// Need a dimension reference — use overworld
const overworld = world.getDimension("overworld");
buildPortal(loc, overworld);
world.sendMessage("§b[Hub] §fReturn portal auto-built near spawn! Use §d!setportal§f to relocate it.");
}, 100);
system.run(() => {
world.sendMessage("§b[World] §fHub return system loaded! Use compass, step on portal, or type §d!hub§f to return.");
});