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:
2026-04-27 22:00:31 +01:00
parent 7c8cd5b075
commit af9d37462c
3 changed files with 258 additions and 28 deletions

View File

@@ -61,6 +61,7 @@ services:
volumes:
- jamie-data:/data
- ./hub-return-addon/hub_return_transfer_BP:/data/behavior_packs/hub_return_transfer_BP
- ./hub-return-addon/hub_return_transfer_RP:/data/resource_packs/hub_return_transfer_RP
- ./private-chest-addon/private_chest_BP:/data/behavior_packs/private_chest_BP
- ./private-chest-addon/private_chest_RP:/data/resource_packs/private_chest_RP
- ./smart-crafting-addon/smart_crafting_BP:/data/behavior_packs/smart_crafting_BP
@@ -76,6 +77,10 @@ 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
- ./trees-features-addon/trees_features_BP:/data/behavior_packs/trees_features_BP
- ./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
restart: unless-stopped
mem_limit: 1500m
memswap_limit: 2500m
@@ -94,6 +99,7 @@ services:
volumes:
- lyla-data:/data
- ./hub-return-addon/hub_return_transfer_BP:/data/behavior_packs/hub_return_transfer_BP
- ./hub-return-addon/hub_return_transfer_RP:/data/resource_packs/hub_return_transfer_RP
- ./addon/spark_pet_BP:/data/behavior_packs/spark_pet_BP
- ./addon/spark_pet_RP:/data/resource_packs/spark_pet_RP
- ./addon/heyhe_pet_BP:/data/behavior_packs/heyhe_pet_BP
@@ -135,6 +141,7 @@ services:
volumes:
- mya-data:/data
- ./hub-return-addon/hub_return_transfer_BP:/data/behavior_packs/hub_return_transfer_BP
- ./hub-return-addon/hub_return_transfer_RP:/data/resource_packs/hub_return_transfer_RP
- ./addon/spark_pet_BP:/data/behavior_packs/spark_pet_BP
- ./addon/spark_pet_RP:/data/resource_packs/spark_pet_RP
- ./addon/heyhe_pet_BP:/data/behavior_packs/heyhe_pet_BP
@@ -157,7 +164,13 @@ 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
- ./naturalist-lite-addon/naturalist_lite_BP:/data/behavior_packs/naturalist_lite_BP
- ./naturalist-lite-addon/naturalist_lite_RP:/data/resource_packs/naturalist_lite_RP
- ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json
- ./trees-features-addon/trees_features_BP:/data/behavior_packs/trees_features_BP
- ./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
restart: unless-stopped
mem_limit: 1500m
memswap_limit: 2500m

View File

@@ -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.)");
});

View 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
]
}
]
}