diff --git a/hub-return-addon/hub_return_transfer_BP/manifest.json b/hub-return-addon/hub_return_transfer_BP/manifest.json index 4ba001f..bcfbcd7 100644 --- a/hub-return-addon/hub_return_transfer_BP/manifest.json +++ b/hub-return-addon/hub_return_transfer_BP/manifest.json @@ -4,7 +4,7 @@ "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, 4], + "version": [1, 0, 5], "min_engine_version": [1, 21, 0] }, "modules": [ @@ -12,7 +12,7 @@ "type": "script", "language": "javascript", "uuid": "b2c3d4e5-4444-5555-6666-fedcba987654", - "version": [1, 0, 4], + "version": [1, 0, 5], "entry": "scripts/main.js" } ], @@ -24,6 +24,10 @@ { "module_name": "@minecraft/server-admin", "version": "1.0.0-beta" + }, + { + "module_name": "@minecraft/server-ui", + "version": "1.3.0" } ] } diff --git a/hub-return-addon/hub_return_transfer_BP/scripts/main.js b/hub-return-addon/hub_return_transfer_BP/scripts/main.js index 98b58ff..5ab7f51 100644 --- a/hub-return-addon/hub_return_transfer_BP/scripts/main.js +++ b/hub-return-addon/hub_return_transfer_BP/scripts/main.js @@ -1,19 +1,82 @@ import { world, system, ItemStack } from "@minecraft/server"; import { transferPlayer, variables } from "@minecraft/server-admin"; +import { ActionFormData, ModalFormData, MessageFormData } from "@minecraft/server-ui"; const LOBBY_HOST = "10.0.0.247"; const LOBBY_PORT = 19132; const COMPASS_ID = "minecraft:recovery_compass"; +const MARKER_BLOCK = "minecraft:lodestone"; const PORTAL_PROP = "hub_portal_location"; +const WAYPOINTS_PROP = "waypoints_v1"; const PORTAL_RADIUS = 3; -const TRANSFER_COOLDOWN = 5000; // 5 seconds -const SPAWN_PROTECTION = 10000; // 10 seconds — ignore portal detection after spawn +const TRANSFER_COOLDOWN = 5000; +const SPAWN_PROTECTION = 10000; + +const MAX_MARKERS_PER_PLAYER = 10; +const ARRIVAL_RADIUS = 3; +const HUD_TICK_INTERVAL = 5; +const ARROWS = ["\u2191", "\u2197", "\u2192", "\u2198", "\u2193", "\u2199", "\u2190", "\u2196"]; -// Track recently transferred players const recentTransfers = new Map(); -// Track when players spawned (to prevent transfer loop on arrival) const spawnTimes = new Map(); +// ─── Waypoint State ───────────────────────────────────────────── + +// Persisted: markers per player { [ownerId]: [ {x,y,z,dim,label,key}, ... ] } +let markers = {}; +// Session-only: which marker each player is actively guiding toward +const active = new Map(); + +function keyOf(loc, dimensionId) { + return `${Math.floor(loc.x)},${Math.floor(loc.y)},${Math.floor(loc.z)},${dimensionId}`; +} + +function loadWaypoints() { + try { + const raw = world.getDynamicProperty(WAYPOINTS_PROP); + if (raw && typeof raw === "string") markers = JSON.parse(raw); + } catch (e) { + world.sendMessage(`§c[Waypoints] Failed to load: ${e.message}`); + markers = {}; + } +} + +function saveWaypoints() { + try { + world.setDynamicProperty(WAYPOINTS_PROP, JSON.stringify(markers)); + } catch (e) { + world.sendMessage(`§c[Waypoints] Failed to save: ${e.message}`); + } +} + +function getMarkers(ownerId) { + if (!markers[ownerId]) markers[ownerId] = []; + return markers[ownerId]; +} + +function findMarkerByKey(key) { + for (const ownerId of Object.keys(markers)) { + const list = markers[ownerId]; + for (let i = 0; i < list.length; i++) { + if (list[i].key === key) return { ownerId, index: i, marker: list[i] }; + } + } + return null; +} + +function removeMarker(ownerId, index) { + const list = getMarkers(ownerId); + list.splice(index, 1); + const activeIdx = active.get(ownerId); + if (activeIdx != null) { + if (activeIdx === index) active.delete(ownerId); + else if (activeIdx > index) active.set(ownerId, activeIdx - 1); + } + saveWaypoints(); +} + +// ─── Hub Transfer ─────────────────────────────────────────────── + function doTransfer(player) { const now = Date.now(); if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) { @@ -24,8 +87,6 @@ function doTransfer(player) { recentTransfers.set(player.name, now); world.sendMessage(`§a${player.name} is returning to the Hub...`); - // Teleport player away from return portal before transfer so their saved - // position is NOT on the portal (prevents re-transfer loop on lobby arrival) const portalJson = world.getDynamicProperty(PORTAL_PROP); if (portalJson) { try { @@ -33,17 +94,15 @@ function doTransfer(player) { const pos = player.location; const dx = Math.abs(pos.x - portal.x); const dz = Math.abs(pos.z - portal.z); - // Only teleport if player is near the return portal if (dx <= PORTAL_RADIUS + 1 && dz <= PORTAL_RADIUS + 1) { const safePos = { x: pos.x, y: pos.y, z: pos.z + 4 }; player.teleport(safePos); } } catch (e) { - // Non-fatal — proceed with transfer anyway + // Non-fatal } } - // Wait 5 ticks for position to save, then transfer system.runTimeout(() => { try { transferPlayer(player, { hostname: LOBBY_HOST, port: LOBBY_PORT }); @@ -53,7 +112,7 @@ function doTransfer(player) { }, 5); } -// ─── A. Compass Distribution ──────────────────────────────────── +// ─── Compass Distribution ─────────────────────────────────────── function giveCompassIfMissing(player) { const inventory = player.getComponent("minecraft:inventory"); @@ -62,48 +121,333 @@ function giveCompassIfMissing(player) { 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 + if (item && item.typeId === COMPASS_ID) return; } container.addItem(new ItemStack(COMPASS_ID, 1)); - player.sendMessage("§b[Hub] §fYou received a §dHub Compass§f — use it to return to the lobby!"); + player.sendMessage("§b[Hub] §fYou received a §dHub Compass§f — right-click to open the menu."); } -// Get the world name from server variables.json for the welcome message let WORLD_NAME = "this world"; try { WORLD_NAME = variables.get("world_name") || "this world"; } catch (e) { - // variables.json may not exist — use fallback + // variables.json may not exist } world.afterEvents.playerSpawn.subscribe((event) => { const player = event.player; - // Mark spawn time for portal protection (prevents transfer loop on arrival) spawnTimes.set(player.name, Date.now()); - // Show welcome title and give compass after a short delay system.runTimeout(() => { try { player.runCommand(`titleraw @s title {"rawtext":[{"text":"§6Welcome!"}]}`); player.runCommand(`titleraw @s subtitle {"rawtext":[{"text":"§7You are now in §e${WORLD_NAME}"}]}`); giveCompassIfMissing(player); } catch (e) { - // Silently ignore — player may have disconnected + // Player may have disconnected } }, 20); }); -// ─── B. Compass Use Transfer ──────────────────────────────────── +world.afterEvents.playerLeave.subscribe((event) => { + // Clear session HUD state for whoever left (keyed by id) + // event.playerId exists; event.playerName for compat + if (event.playerId) active.delete(event.playerId); +}); + +// ─── Compass Menu ─────────────────────────────────────────────── world.afterEvents.itemUse.subscribe((event) => { const player = event.source; - const item = event.itemStack; - - if (item.typeId !== COMPASS_ID) return; - doTransfer(player); + if (event.itemStack.typeId !== COMPASS_ID) return; + system.run(() => openCompassMenu(player)); }); -// ─── C. Portal Zone Detection ─────────────────────────────────── +async function openCompassMenu(player) { + const list = getMarkers(player.id); + const dimId = player.dimension.id; + const visible = list + .map((m, i) => ({ m, i, dist: distanceXZ(player.location, m) })) + .filter(({ m }) => m.dim === dimId); + + const form = new ActionFormData() + .title("Hub Compass") + .body(`§7Waypoints: §f${list.length}§7 / ${MAX_MARKERS_PER_PLAYER}`); + + const actions = []; + form.button("\uD83C\uDFE0 Return to Hub"); + actions.push({ kind: "hub" }); + + for (const { m, i, dist } of visible) { + form.button(`§f${m.label}\n§7${Math.round(dist)}m away`); + actions.push({ kind: "select", index: i }); + } + + const activeIdx = active.get(player.id); + if (activeIdx != null && list[activeIdx]) { + form.button(`§c\u274C Clear active waypoint`); + actions.push({ kind: "clear" }); + } + + if (list.length > 0) { + form.button("\uD83D\uDDD1 Delete a waypoint…"); + actions.push({ kind: "delete_menu" }); + } + + form.button("\uD83E\uDDED Get a marker block"); + actions.push({ kind: "give_marker" }); + + form.button("§7Cancel"); + actions.push({ kind: "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 || a.kind === "cancel") return; + + if (a.kind === "hub") { + doTransfer(player); + return; + } + if (a.kind === "select") { + active.set(player.id, a.index); + const m = list[a.index]; + player.sendMessage(`§b[Waypoints] §fGuiding you to §d${m.label}§f.`); + return; + } + if (a.kind === "clear") { + active.delete(player.id); + player.onScreenDisplay.setActionBar(""); + player.sendMessage("§b[Waypoints] §fActive waypoint cleared."); + return; + } + if (a.kind === "delete_menu") { + await openDeleteMenu(player); + return; + } + if (a.kind === "give_marker") { + giveMarkerBlock(player); + return; + } +} + +function giveMarkerBlock(player) { + try { + const inv = player.getComponent("minecraft:inventory")?.container; + if (!inv) { + player.sendMessage("§c[Waypoints] §fCouldn't access your inventory."); + return; + } + const leftover = inv.addItem(new ItemStack(MARKER_BLOCK, 1)); + if (leftover) { + const pos = player.location; + player.dimension.spawnItem(new ItemStack(MARKER_BLOCK, 1), { x: pos.x, y: pos.y + 1, z: pos.z }); + player.sendMessage("§b[Waypoints] §fInventory full — dropped a §dlodestone§f at your feet."); + } else { + player.sendMessage("§b[Waypoints] §fHere's a §dlodestone§f — place it to set a waypoint."); + } + } catch (e) { + player.sendMessage(`§c[Waypoints] §fCouldn't give lodestone: ${e.message}`); + } +} + +async function openDeleteMenu(player) { + const list = getMarkers(player.id); + if (list.length === 0) return; + + const form = new ActionFormData() + .title("Delete a Waypoint") + .body("§7Pick a waypoint to remove. The lodestone block stays — break it to clean up."); + + for (const m of list) { + form.button(`§f${m.label}\n§7${m.x}, ${m.y}, ${m.z}`); + } + form.button("§7Cancel"); + + let res; + try { res = await form.show(player); } catch (_) { return; } + if (res.canceled || res.selection === undefined) return; + if (res.selection >= list.length) return; + + const chosen = list[res.selection]; + const confirm = new MessageFormData() + .title("Confirm Delete") + .body(`Remove waypoint §f${chosen.label}§r?`) + .button1("Delete") + .button2("Cancel"); + + let cf; + try { cf = await confirm.show(player); } catch (_) { return; } + if (cf.canceled || cf.selection !== 0) return; + + removeMarker(player.id, res.selection); + player.sendMessage(`§b[Waypoints] §fDeleted §d${chosen.label}§f.`); +} + +// ─── Placement → Label Prompt ─────────────────────────────────── + +world.afterEvents.playerPlaceBlock.subscribe((event) => { + if (event.block.typeId !== MARKER_BLOCK) return; + const player = event.player; + const block = event.block; + const dim = block.dimension; + const loc = { x: block.location.x, y: block.location.y, z: block.location.z }; + + const list = getMarkers(player.id); + if (list.length >= MAX_MARKERS_PER_PLAYER) { + system.run(() => { + try { + dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`); + dim.spawnItem(new ItemStack(MARKER_BLOCK, 1), { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 }); + } catch (_) {} + player.sendMessage(`§c[Waypoints] §fMax of ${MAX_MARKERS_PER_PLAYER} waypoints reached — delete one first.`); + }); + return; + } + + system.run(() => promptForLabel(player, loc, dim.id, list.length)); +}); + +async function promptForLabel(player, loc, dimId, nextIndex) { + const form = new ModalFormData() + .title("Name this Waypoint") + .textField("Label", "e.g. Mine, Base, Farm", `Waypoint ${nextIndex + 1}`); + + let res; + try { res = await form.show(player); } catch (_) { return; } + if (res.canceled) { + // Still register with default label so the physical lodestone isn't orphaned + saveMarker(player, loc, dimId, `Waypoint ${nextIndex + 1}`); + return; + } + + const raw = (res.formValues && res.formValues[0]) || ""; + const label = String(raw).trim().slice(0, 24) || `Waypoint ${nextIndex + 1}`; + saveMarker(player, loc, dimId, label); +} + +function saveMarker(player, loc, dimId, label) { + const list = getMarkers(player.id); + const key = keyOf(loc, dimId); + if (list.some((m) => m.key === key)) return; // already registered (e.g. re-placed) + list.push({ + x: Math.floor(loc.x), + y: Math.floor(loc.y), + z: Math.floor(loc.z), + dim: dimId, + label, + key, + }); + saveWaypoints(); + player.sendMessage(`§b[Waypoints] §fSaved §d${label}§f — select it from the compass to navigate.`); +} + +// ─── Break → Unregister ───────────────────────────────────────── + +try { + world.beforeEvents.playerBreakBlock.subscribe((event) => { + const block = event.block; + if (block.typeId !== MARKER_BLOCK) return; + + const key = keyOf(block.location, block.dimension.id); + const hit = findMarkerByKey(key); + if (!hit) return; // untracked lodestone — normal vanilla break + + const player = event.player; + if (hit.ownerId !== player.id) { + event.cancel = true; + const ownerName = findOwnerName(hit.ownerId); + system.run(() => + player.sendMessage( + `§c[Waypoints] §7This waypoint belongs to §f${ownerName}§7. You can't break it.` + ) + ); + return; + } + + event.cancel = true; + const loc = { x: block.location.x, y: block.location.y, z: block.location.z }; + const dim = block.dimension; + const label = hit.marker.label; + system.run(() => { + try { + const dropPos = { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 }; + dim.spawnItem(new ItemStack(MARKER_BLOCK, 1), dropPos); + dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`); + } catch (e) { + player.sendMessage(`§c[Waypoints] Error during break: ${e.message}`); + } + removeMarker(hit.ownerId, hit.index); + player.sendMessage(`§b[Waypoints] §fRemoved §d${label}§f.`); + }); + }); +} catch (e) { + console.warn(`[Waypoints] beforeEvents.playerBreakBlock unavailable: ${e}`); +} + +function findOwnerName(ownerId) { + for (const p of world.getAllPlayers()) if (p.id === ownerId) return p.name; + return "another player"; +} + +// ─── Guidance HUD ─────────────────────────────────────────────── + +function distanceXZ(a, b) { + const dx = (b.x + 0.5) - a.x; + const dz = (b.z + 0.5) - a.z; + return Math.sqrt(dx * dx + dz * dz); +} + +system.runInterval(() => { + for (const player of world.getAllPlayers()) { + const idx = active.get(player.id); + if (idx == null) continue; + + const list = markers[player.id]; + const m = list && list[idx]; + if (!m) { + active.delete(player.id); + continue; + } + if (m.dim !== player.dimension.id) { + player.onScreenDisplay.setActionBar(`§b\u27A4 §f${m.label} §7• §cdifferent dimension`); + continue; + } + + const pos = player.location; + const dx = (m.x + 0.5) - pos.x; + const dz = (m.z + 0.5) - pos.z; + const distXZ = Math.sqrt(dx * dx + dz * dz); + + if (distXZ <= ARRIVAL_RADIUS) { + active.delete(player.id); + try { + player.onScreenDisplay.setTitle(`§aArrived`, { subtitle: `§f${m.label}`, fadeInDuration: 5, stayDuration: 30, fadeOutDuration: 10 }); + player.onScreenDisplay.setActionBar(""); + } catch (_) {} + continue; + } + + // Bedrock yaw: 0 faces +Z (south), +yaw turns clockwise (toward -X, i.e. west). + // Bearing to target in the same yaw convention: + const bearingDeg = (Math.atan2(-dx, dz) * 180 / Math.PI + 360) % 360; + let yaw; + try { yaw = player.getRotation().y; } catch (_) { continue; } + const relative = ((bearingDeg - yaw + 540) % 360) - 180; // -180..180 + const bucket = ((Math.round(relative / 45) % 8) + 8) % 8; + const arrow = ARROWS[bucket]; + + try { + player.onScreenDisplay.setActionBar( + `§b\u27A4 §f${m.label} §7• §f${Math.round(distXZ)}m §8[§a${arrow}§8]` + ); + } catch (_) {} + } +}, HUD_TICK_INTERVAL); + +// ─── Portal Zone Detection ────────────────────────────────────── system.runInterval(() => { const portalJson = world.getDynamicProperty(PORTAL_PROP); @@ -120,14 +464,8 @@ system.runInterval(() => { const players = world.getAllPlayers(); for (const player of players) { - // Skip if recently transferred (cooldown) - if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) { - continue; - } - // Skip if player just spawned (prevents loop when arriving from lobby) - if (spawnTimes.has(player.name) && now - spawnTimes.get(player.name) < SPAWN_PROTECTION) { - continue; - } + if (recentTransfers.has(player.name) && now - recentTransfers.get(player.name) < TRANSFER_COOLDOWN) continue; + if (spawnTimes.has(player.name) && now - spawnTimes.get(player.name) < SPAWN_PROTECTION) continue; const pos = player.location; const dx = Math.abs(pos.x - portal.x); @@ -140,7 +478,7 @@ system.runInterval(() => { } }, 20); -// ─── D. Chat Commands ─────────────────────────────────────────── +// ─── Chat Commands ────────────────────────────────────────────── function handleChatCommand(player, msg) { if (msg === "!compass") { @@ -155,11 +493,7 @@ function handleChatCommand(player, msg) { if (msg === "!setportal") { const pos = player.location; - const loc = { - x: Math.floor(pos.x), - y: Math.floor(pos.y), - z: Math.floor(pos.z), - }; + 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!`); @@ -171,10 +505,37 @@ function handleChatCommand(player, msg) { return true; } + if (msg === "!waypoints") { + system.run(() => openCompassMenu(player)); + return true; + } + + if (msg === "!marker") { + giveMarkerBlock(player); + return true; + } + + if (msg === "!clearwaypoints") { + system.run(async () => { + const confirm = new MessageFormData() + .title("Clear all waypoints?") + .body("§7This deletes every waypoint you've saved. Lodestone blocks stay in the world — break them to reclaim.") + .button1("§cDelete all") + .button2("Cancel"); + let res; + try { res = await confirm.show(player); } catch (_) { return; } + if (res.canceled || res.selection !== 0) return; + markers[player.id] = []; + active.delete(player.id); + saveWaypoints(); + player.sendMessage("§b[Waypoints] §fAll your waypoints were cleared."); + }); + return true; + } + return false; } -// Listen for chat commands — try beforeEvents first (beta API), fall back gracefully try { world.beforeEvents.chatSend.subscribe((event) => { const msg = event.message.trim().toLowerCase(); @@ -185,12 +546,9 @@ try { } }); } catch (e) { - // beforeEvents.chatSend requires beta API — chat commands won't work via chat, - // but compass and portal transfers still function world.sendMessage("§e[Hub] §fChat commands (!hub, !lobby) unavailable — use compass or portal instead."); } -// scriptEvent fallback — players can use: /scriptevent hub:cmd hub system.afterEvents.scriptEventReceive.subscribe((event) => { if (event.id !== "hub:cmd") return; const player = event.sourceEntity; @@ -199,7 +557,7 @@ system.afterEvents.scriptEventReceive.subscribe((event) => { handleChatCommand(player, `!${msg}`); }); -// ─── E. Portal Auto-Build on First Load ───────────────────────── +// ─── Portal Auto-Build ────────────────────────────────────────── function buildPortal(loc, dimension) { const x = loc.x; @@ -207,57 +565,39 @@ function buildPortal(loc, dimension) { 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 + // Non-fatal } } } system.runTimeout(() => { const existing = world.getDynamicProperty(PORTAL_PROP); - if (existing) return; // Portal location already set + if (existing) return; - // 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, - }; + 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); @@ -265,5 +605,6 @@ system.runTimeout(() => { }, 100); system.run(() => { - world.sendMessage("§b[World] §fHub return system loaded! Use compass, step on portal, or type §d!hub§f to return."); + loadWaypoints(); + world.sendMessage("§b[World] §fHub return system loaded! Place a §dlodestone§f to set a waypoint; right-click your compass to navigate."); });