feat(hub-return): subtitle nav HUD, share waypoints, !nav fallback
Move the directional waypoint HUD off the action bar (which fights mount saddle/jump UI for screen space) into the title/subtitle slot — large rotating arrow + distance up top, label underneath, refreshed every 5 ticks so it stays pinned. Active waypoint now persists across container restarts via per-player dynamic property instead of an in-memory Map. New: - !share command + 📤 button on the compass form: pick a waypoint, pick a recipient, send them an Accept/Decline prompt; copies into their list as "Label (from sender)" with capacity check. - !nav chat fallback: list waypoints with distances, switch active with !nav <n>, !nav off to clear. - hub_return_transfer_RP scaffold for future asset overrides. docker-compose: mount the new RP on jamie/lyla/mya. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,33 @@ const ARRIVAL_RADIUS = 3;
|
||||
const HUD_TICK_INTERVAL = 5;
|
||||
const ARROWS = ["\u2191", "\u2197", "\u2192", "\u2198", "\u2193", "\u2199", "\u2190", "\u2196"];
|
||||
|
||||
// Navigation HUD lives in the title/subtitle slot so it doesn't fight the
|
||||
// vanilla mount UI for the action bar. The directional content goes in the
|
||||
// TITLE position (large, central) with the waypoint label in the subtitle as
|
||||
// a secondary line. Empty-string title gets suppressed by some Bedrock client
|
||||
// versions, so we always pass a non-empty title — that's what causes the
|
||||
// arrival message to render but a setActionBar-replacement subtitle not to.
|
||||
function showNav(player, mainLine, subLine) {
|
||||
try {
|
||||
player.onScreenDisplay.setTitle(mainLine, {
|
||||
subtitle: subLine ?? "",
|
||||
fadeInDuration: 0,
|
||||
stayDuration: HUD_TICK_INTERVAL * 4,
|
||||
fadeOutDuration: 0,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
function clearNav(player) {
|
||||
try {
|
||||
player.onScreenDisplay.setTitle(" ", {
|
||||
subtitle: "",
|
||||
fadeInDuration: 0,
|
||||
stayDuration: 1,
|
||||
fadeOutDuration: 0,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const recentTransfers = new Map();
|
||||
const spawnTimes = new Map();
|
||||
|
||||
@@ -24,8 +51,22 @@ const spawnTimes = new Map();
|
||||
|
||||
// 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();
|
||||
// Per-player active waypoint index, persisted via player dynamic property so
|
||||
// it survives container restarts and reconnects.
|
||||
const ACTIVE_PROP = "hub_active_waypoint_v1";
|
||||
function getActiveIndex(player) {
|
||||
try {
|
||||
const v = player.getDynamicProperty(ACTIVE_PROP);
|
||||
return typeof v === "number" ? v : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function setActiveIndex(player, idx) {
|
||||
try {
|
||||
if (idx == null) player.setDynamicProperty(ACTIVE_PROP, undefined);
|
||||
else player.setDynamicProperty(ACTIVE_PROP, idx);
|
||||
} catch (_) {}
|
||||
}
|
||||
function clearActiveIndex(player) { setActiveIndex(player, null); }
|
||||
|
||||
function keyOf(loc, dimensionId) {
|
||||
return `${Math.floor(loc.x)},${Math.floor(loc.y)},${Math.floor(loc.z)},${dimensionId}`;
|
||||
@@ -67,10 +108,16 @@ function findMarkerByKey(key) {
|
||||
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);
|
||||
// Active index is stored on the owner's player dynamic property; update only
|
||||
// if they're online (offline owner will revalidate their own index next tick).
|
||||
for (const p of world.getAllPlayers()) {
|
||||
if (p.id !== ownerId) continue;
|
||||
const activeIdx = getActiveIndex(p);
|
||||
if (activeIdx != null) {
|
||||
if (activeIdx === index) clearActiveIndex(p);
|
||||
else if (activeIdx > index) setActiveIndex(p, activeIdx - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
saveWaypoints();
|
||||
}
|
||||
@@ -149,11 +196,8 @@ world.afterEvents.playerSpawn.subscribe((event) => {
|
||||
}, 20);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
// Active waypoint is persisted as a player dynamic property, so playerLeave
|
||||
// does not need to clear any in-memory session state.
|
||||
|
||||
// ─── Compass Menu ───────────────────────────────────────────────
|
||||
|
||||
@@ -183,7 +227,7 @@ async function openCompassMenu(player) {
|
||||
actions.push({ kind: "select", index: i });
|
||||
}
|
||||
|
||||
const activeIdx = active.get(player.id);
|
||||
const activeIdx = getActiveIndex(player);
|
||||
if (activeIdx != null && list[activeIdx]) {
|
||||
form.button(`§c\u274C Clear active waypoint`);
|
||||
actions.push({ kind: "clear" });
|
||||
@@ -192,6 +236,9 @@ async function openCompassMenu(player) {
|
||||
if (list.length > 0) {
|
||||
form.button("\uD83D\uDDD1 Delete a waypoint…");
|
||||
actions.push({ kind: "delete_menu" });
|
||||
|
||||
form.button("\uD83D\uDCE4 Share with another player…");
|
||||
actions.push({ kind: "share" });
|
||||
}
|
||||
|
||||
form.button("\uD83E\uDDED Get a marker block");
|
||||
@@ -212,14 +259,14 @@ async function openCompassMenu(player) {
|
||||
return;
|
||||
}
|
||||
if (a.kind === "select") {
|
||||
active.set(player.id, a.index);
|
||||
setActiveIndex(player, a.index);
|
||||
const m = list[a.index];
|
||||
player.sendMessage(`§b[Waypoints] §fGuiding you to §d${m.label}§f.`);
|
||||
player.sendMessage(`§b[Waypoints] §fGuiding you to §d${m.label}§f. §8(arrow appears at the top of your screen)`);
|
||||
return;
|
||||
}
|
||||
if (a.kind === "clear") {
|
||||
active.delete(player.id);
|
||||
player.onScreenDisplay.setActionBar("");
|
||||
clearActiveIndex(player);
|
||||
clearNav(player);
|
||||
player.sendMessage("§b[Waypoints] §fActive waypoint cleared.");
|
||||
return;
|
||||
}
|
||||
@@ -227,6 +274,10 @@ async function openCompassMenu(player) {
|
||||
await openDeleteMenu(player);
|
||||
return;
|
||||
}
|
||||
if (a.kind === "share") {
|
||||
await openShareMenu(player);
|
||||
return;
|
||||
}
|
||||
if (a.kind === "give_marker") {
|
||||
giveMarkerBlock(player);
|
||||
return;
|
||||
@@ -286,6 +337,102 @@ async function openDeleteMenu(player) {
|
||||
player.sendMessage(`§b[Waypoints] §fDeleted §d${chosen.label}§f.`);
|
||||
}
|
||||
|
||||
// ─── Share Waypoint ─────────────────────────────────────────────
|
||||
// Pick one of your waypoints, pick an online player in this world, send
|
||||
// them a confirm prompt. On accept, the waypoint is copied into the
|
||||
// recipient's list with a `(from <sender>)` suffix so it's clearly
|
||||
// attributed. Cross-world shares are blocked here — a player on the lyla
|
||||
// server can't be reached from jamie. Same dimension is preferred but
|
||||
// cross-dim is allowed; the HUD already labels "different dimension".
|
||||
|
||||
async function openShareMenu(sender) {
|
||||
const list = getMarkers(sender.id);
|
||||
if (list.length === 0) {
|
||||
sender.sendMessage("§7[Share] You have no waypoints to share.");
|
||||
return;
|
||||
}
|
||||
const others = world.getAllPlayers().filter((p) => p.id !== sender.id);
|
||||
if (others.length === 0) {
|
||||
sender.sendMessage("§7[Share] No other players are online in this world.");
|
||||
return;
|
||||
}
|
||||
|
||||
const wpForm = new ActionFormData()
|
||||
.title("Share a Waypoint")
|
||||
.body("§7Pick which waypoint to share:");
|
||||
for (const m of list) {
|
||||
wpForm.button(`§f${m.label}\n§7${m.x}, ${m.y}, ${m.z}`);
|
||||
}
|
||||
wpForm.button("§7Cancel");
|
||||
|
||||
let wpRes;
|
||||
try { wpRes = await wpForm.show(sender); } catch (_) { return; }
|
||||
if (wpRes.canceled || wpRes.selection === undefined) return;
|
||||
if (wpRes.selection >= list.length) return;
|
||||
const marker = list[wpRes.selection];
|
||||
|
||||
const recipForm = new ActionFormData()
|
||||
.title(`Share "${marker.label}"`)
|
||||
.body("§7Who should receive this waypoint?");
|
||||
for (const p of others) recipForm.button(`§f${p.name}`);
|
||||
recipForm.button("§7Cancel");
|
||||
|
||||
let rRes;
|
||||
try { rRes = await recipForm.show(sender); } catch (_) { return; }
|
||||
if (rRes.canceled || rRes.selection === undefined) return;
|
||||
if (rRes.selection >= others.length) return;
|
||||
const recipient = others[rRes.selection];
|
||||
|
||||
// Receiver capacity check before bothering them with a prompt
|
||||
const recList = getMarkers(recipient.id);
|
||||
if (recList.length >= MAX_MARKERS_PER_PLAYER) {
|
||||
sender.sendMessage(`§c[Share] §f${recipient.name}'s waypoint slots are full.`);
|
||||
return;
|
||||
}
|
||||
|
||||
sender.sendMessage(`§b[Share] §fSent §d${marker.label}§f to §e${recipient.name}§f — waiting for them to accept…`);
|
||||
|
||||
const offer = new MessageFormData()
|
||||
.title("Waypoint Shared")
|
||||
.body(`§e${sender.name}§r wants to share a waypoint with you:\n\n§f${marker.label}\n§7${marker.x}, ${marker.y}, ${marker.z}\n\nAdd to your compass?`)
|
||||
.button1("§aAccept")
|
||||
.button2("§cDecline");
|
||||
|
||||
let oRes;
|
||||
try { oRes = await offer.show(recipient); } catch (_) {
|
||||
sender.sendMessage(`§c[Share] §f${recipient.name} couldn't be reached.`);
|
||||
return;
|
||||
}
|
||||
if (oRes.canceled || oRes.selection !== 0) {
|
||||
sender.sendMessage(`§7[Share] §f${recipient.name} declined.`);
|
||||
recipient.sendMessage(`§7[Share] §fDeclined waypoint from §e${sender.name}§f.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check capacity after the await — markers may have changed
|
||||
if (recList.length >= MAX_MARKERS_PER_PLAYER) {
|
||||
sender.sendMessage(`§c[Share] §f${recipient.name}'s waypoint slots are full.`);
|
||||
recipient.sendMessage(`§c[Share] §fYour waypoint slots are full — couldn't accept §d${marker.label}§f.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedLabel = `${marker.label} (from ${sender.name})`.slice(0, 40);
|
||||
// Use a key that won't collide with the receiver's own lodestones
|
||||
const sharedKey = `shared:${sender.id}:${marker.key}`;
|
||||
recList.push({
|
||||
x: marker.x,
|
||||
y: marker.y,
|
||||
z: marker.z,
|
||||
dim: marker.dim,
|
||||
label: sharedLabel,
|
||||
key: sharedKey,
|
||||
});
|
||||
saveWaypoints();
|
||||
|
||||
sender.sendMessage(`§a[Share] §f${recipient.name} accepted §d${marker.label}§f.`);
|
||||
recipient.sendMessage(`§a[Share] §fAdded §d${sharedLabel}§f. Type §e!nav§f to find it.`);
|
||||
}
|
||||
|
||||
// ─── Placement → Label Prompt ───────────────────────────────────
|
||||
|
||||
world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||
@@ -402,17 +549,17 @@ function distanceXZ(a, b) {
|
||||
|
||||
system.runInterval(() => {
|
||||
for (const player of world.getAllPlayers()) {
|
||||
const idx = active.get(player.id);
|
||||
const idx = getActiveIndex(player);
|
||||
if (idx == null) continue;
|
||||
|
||||
const list = markers[player.id];
|
||||
const m = list && list[idx];
|
||||
if (!m) {
|
||||
active.delete(player.id);
|
||||
clearActiveIndex(player);
|
||||
continue;
|
||||
}
|
||||
if (m.dim !== player.dimension.id) {
|
||||
player.onScreenDisplay.setActionBar(`§b\u27A4 §f${m.label} §7• §cdifferent dimension`);
|
||||
showNav(player, `§c⚠ Different Dimension`, `§7${m.label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -422,10 +569,9 @@ system.runInterval(() => {
|
||||
const distXZ = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (distXZ <= ARRIVAL_RADIUS) {
|
||||
active.delete(player.id);
|
||||
clearActiveIndex(player);
|
||||
try {
|
||||
player.onScreenDisplay.setTitle(`§aArrived`, { subtitle: `§f${m.label}`, fadeInDuration: 5, stayDuration: 30, fadeOutDuration: 10 });
|
||||
player.onScreenDisplay.setActionBar("");
|
||||
} catch (_) {}
|
||||
continue;
|
||||
}
|
||||
@@ -439,11 +585,7 @@ system.runInterval(() => {
|
||||
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 (_) {}
|
||||
showNav(player, `§a${arrow} §f${Math.round(distXZ)}m`, `§7${m.label}`);
|
||||
}
|
||||
}, HUD_TICK_INTERVAL);
|
||||
|
||||
@@ -515,6 +657,52 @@ function handleChatCommand(player, msg) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg === "!share") {
|
||||
system.run(() => openShareMenu(player));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg === "!nav" || msg.startsWith("!nav ")) {
|
||||
const list = getMarkers(player.id);
|
||||
const arg = msg.slice(4).trim();
|
||||
if (arg === "off" || arg === "clear") {
|
||||
clearActiveIndex(player);
|
||||
clearNav(player);
|
||||
player.sendMessage("§b[Waypoints] §fNavigation cleared.");
|
||||
return true;
|
||||
}
|
||||
if (arg) {
|
||||
const n = Number.parseInt(arg, 10);
|
||||
if (Number.isFinite(n) && n >= 1 && n <= list.length) {
|
||||
setActiveIndex(player, n - 1);
|
||||
const m = list[n - 1];
|
||||
player.sendMessage(`§b[Waypoints] §fGuiding you to §d${m.label}§f. §8(arrow at top of screen)`);
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(`§c[Waypoints] §fNo waypoint #${arg} — type §e!nav§f to list yours.`);
|
||||
return true;
|
||||
}
|
||||
if (list.length === 0) {
|
||||
player.sendMessage("§7[Waypoints] No waypoints yet — place a §dlodestone§7 to set one.");
|
||||
return true;
|
||||
}
|
||||
const idx = getActiveIndex(player);
|
||||
const cur = idx != null ? list[idx] : null;
|
||||
if (cur) {
|
||||
const dist = Math.round(distanceXZ(player.location, cur));
|
||||
player.sendMessage(`§b[Waypoints] §fActive: §d${cur.label}§f (${dist}m). Type §e!nav off§f to clear, or §e!nav <number>§f to switch.`);
|
||||
} else {
|
||||
player.sendMessage("§b[Waypoints] §fNo active waypoint. Pick one:");
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const m = list[i];
|
||||
const here = m.dim === player.dimension.id;
|
||||
const tag = here ? `§7${Math.round(distanceXZ(player.location, m))}m` : "§8(other dim)";
|
||||
player.sendMessage(` §e${i + 1}§7. §f${m.label} ${tag}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg === "!clearwaypoints") {
|
||||
system.run(async () => {
|
||||
const confirm = new MessageFormData()
|
||||
@@ -526,7 +714,7 @@ function handleChatCommand(player, msg) {
|
||||
try { res = await confirm.show(player); } catch (_) { return; }
|
||||
if (res.canceled || res.selection !== 0) return;
|
||||
markers[player.id] = [];
|
||||
active.delete(player.id);
|
||||
clearActiveIndex(player);
|
||||
saveWaypoints();
|
||||
player.sendMessage("§b[Waypoints] §fAll your waypoints were cleared.");
|
||||
});
|
||||
@@ -606,5 +794,5 @@ system.runTimeout(() => {
|
||||
|
||||
system.run(() => {
|
||||
loadWaypoints();
|
||||
world.sendMessage("§b[World] §fHub return system loaded! Place a §dlodestone§f to set a waypoint; right-click your compass to navigate.");
|
||||
world.sendMessage("§b[World] §fHub return system loaded! Place a §dlodestone§f to set a waypoint, then right-click your compass and pick one — the §anavigation arrow§f shows at the §atop§f of your screen. (Type §e!nav§f for chat fallback.)");
|
||||
});
|
||||
|
||||
29
hub-return-addon/hub_return_transfer_RP/manifest.json
Normal file
29
hub-return-addon/hub_return_transfer_RP/manifest.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"header": {
|
||||
"name": "Hub Return Resources",
|
||||
"description": "Recovery compass icon override + future hub-return assets",
|
||||
"uuid": "b1f7e2a4-3c5d-49b8-9d22-6a4f0c87e511",
|
||||
"version": [
|
||||
1,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"min_engine_version": [
|
||||
1,
|
||||
21,
|
||||
0
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"type": "resources",
|
||||
"uuid": "c4a13e85-2f96-4d1a-b772-9e0f3b4d6c21",
|
||||
"version": [
|
||||
1,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user