feat(camping): add craftable tent and hammock addon
All checks were successful
Deploy Addons / deploy (push) Successful in 13s

Tent pitches over a 2x3 flat footprint and lets players skip to dawn
without touching their spawn point. Hammock strings between two posts
3-6 blocks apart (straight or diagonal, +/-1 block height) and keeps
hostile mobs at bay while occupied. Both are craftable (wool/sticks and
wool/string) and mounted in all four worlds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 23:10:21 +01:00
parent 579dfec633
commit 8b83e324f0
20 changed files with 676 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:hammock_cloth"
},
"components": {
"minecraft:destructible_by_mining": {
"seconds_to_destroy": 0.3
},
"minecraft:destructible_by_explosion": {
"explosion_resistance": 0.5
},
"minecraft:map_color": "#B43C37",
"minecraft:material_instances": {
"*": {
"texture": "hammock_cloth",
"render_method": "alpha_test"
}
},
"minecraft:collision_box": {
"origin": [-8, 0, -8],
"size": [16, 4, 16]
},
"minecraft:selection_box": {
"origin": [-8, 0, -8],
"size": [16, 4, 16]
},
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:light_dampening": 0
}
}
}

View File

@@ -0,0 +1,24 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:tent_canvas"
},
"components": {
"minecraft:destructible_by_mining": {
"seconds_to_destroy": 0.4
},
"minecraft:destructible_by_explosion": {
"explosion_resistance": 1.0
},
"minecraft:map_color": "#547A4E",
"minecraft:material_instances": {
"*": {
"texture": "tent_canvas",
"render_method": "alpha_test"
}
},
"minecraft:light_dampening": 1
}
}
}

View File

@@ -0,0 +1,19 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:hammock",
"menu_category": {
"category": "equipment",
"group": "itemGroup.name.miscellaneous"
}
},
"components": {
"minecraft:max_stack_size": 16,
"minecraft:icon": {
"texture": "hammock_item"
},
"minecraft:hand_equipped": true
}
}
}

View File

@@ -0,0 +1,19 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:tent",
"menu_category": {
"category": "equipment",
"group": "itemGroup.name.miscellaneous"
}
},
"components": {
"minecraft:max_stack_size": 16,
"minecraft:icon": {
"texture": "tent_item"
},
"minecraft:hand_equipped": true
}
}
}

View File

@@ -0,0 +1,34 @@
{
"format_version": 2,
"header": {
"name": "Camping Supplies",
"description": "Craftable tent and hammock for overnight camping without setting your spawn point",
"uuid": "bcf569fa-8b2c-403e-9f75-6b405132c5cd",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "data",
"uuid": "f306e1d8-3c13-4554-9715-4799ce6d41d8",
"version": [1, 0, 0]
},
{
"type": "script",
"language": "javascript",
"uuid": "1e496657-0c83-4707-a1e8-29b757dcce79",
"version": [1, 0, 0],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "1.17.0"
},
{
"uuid": "36f12107-10c6-484c-a0f2-b5dd88cd5baa",
"version": [1, 0, 0]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,22 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:hammock_recipe"
},
"tags": ["crafting_table"],
"unlock": { "context": "AlwaysUnlocked" },
"pattern": [
"T T",
"TWWWT"
],
"key": {
"W": { "item": "minecraft:white_wool" },
"T": { "item": "minecraft:string" }
},
"result": {
"item": "silverlabs:hammock",
"count": 1
}
}
}

View File

@@ -0,0 +1,23 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:tent_recipe"
},
"tags": ["crafting_table"],
"unlock": { "context": "AlwaysUnlocked" },
"pattern": [
" W ",
"WWW",
"S S"
],
"key": {
"W": { "item": "minecraft:white_wool" },
"S": { "item": "minecraft:stick" }
},
"result": {
"item": "silverlabs:tent",
"count": 1
}
}
}

View File

@@ -0,0 +1,442 @@
import { world, system, ItemStack } from "@minecraft/server";
// ─── Constants ──────────────────────────────────────────────
const TENT_ITEM = "silverlabs:tent";
const HAMMOCK_ITEM = "silverlabs:hammock";
const TENT_BLOCK = "silverlabs:tent_canvas";
const HAMMOCK_BLOCK = "silverlabs:hammock_cloth";
const STATE_PROP = "camping_state_v1";
const HAMMOCK_TAG = "camping_hammock";
// ─── State ──────────────────────────────────────────────────
// tents: key = "ox,oy,oz,dim" -> { ownerId, ownerName, cells: [[x,y,z]...] }
// hammocks: key = "ax,ay,az->bx,by,bz,dim" -> { ownerId, ownerName, anchorA, anchorB, cells }
let state = { tents: {}, hammocks: {} };
function loadState() {
try {
const raw = world.getDynamicProperty(STATE_PROP);
if (raw && typeof raw === "string") {
const parsed = JSON.parse(raw);
state = {
tents: parsed.tents || {},
hammocks: parsed.hammocks || {},
};
}
} catch (e) {
world.sendMessage(`§c[Camping] state load failed: ${e.message}`);
}
}
function saveState() {
try {
world.setDynamicProperty(STATE_PROP, JSON.stringify(state));
} catch (e) {
world.sendMessage(`§c[Camping] state save failed: ${e.message}`);
}
}
function keyOf(x, y, z, dimId) {
return `${x},${y},${z},${dimId}`;
}
// ─── Orientation helpers ────────────────────────────────────
function cardinalFacing(yaw) {
let y = yaw;
while (y > 180) y -= 360;
while (y < -180) y += 360;
if (y >= -45 && y < 45) return "south";
if (y >= 45 && y < 135) return "west";
if (y >= -135 && y < -45) return "east";
return "north";
}
function vecsForFacing(facing) {
switch (facing) {
case "north": return { fx: 0, fz: -1, rx: 1, rz: 0 };
case "south": return { fx: 0, fz: 1, rx: -1, rz: 0 };
case "east": return { fx: 1, fz: 0, rx: 0, rz: -1 };
case "west": return { fx: -1, fz: 0, rx: 0, rz: 1 };
}
return { fx: 0, fz: 1, rx: -1, rz: 0 };
}
// ─── Inventory helpers ──────────────────────────────────────
function consumeOneOfType(player, typeId) {
const inv = player.getComponent("inventory")?.container;
if (!inv) return false;
const slot = player.selectedSlotIndex;
const item = inv.getItem(slot);
if (item && item.typeId === typeId) {
if (item.amount > 1) {
item.amount -= 1;
inv.setItem(slot, item);
} else {
inv.setItem(slot, undefined);
}
return true;
}
for (let i = 0; i < inv.size; i++) {
const it = inv.getItem(i);
if (it && it.typeId === typeId) {
if (it.amount > 1) {
it.amount -= 1;
inv.setItem(i, it);
} else {
inv.setItem(i, undefined);
}
return true;
}
}
return false;
}
// ─── Tent placement (2×3 footprint, ridge-tunnel shape) ─────
function tryPlaceTent(player) {
const dim = player.dimension;
const facing = cardinalFacing(player.getRotation().y);
const { fx, fz, rx, rz } = vecsForFacing(facing);
const feet = {
x: Math.floor(player.location.x),
y: Math.floor(player.location.y),
z: Math.floor(player.location.z),
};
const ox = feet.x + fx;
const oy = feet.y;
const oz = feet.z + fz;
const groundCells = [];
const clearCells = [];
for (let l = 0; l < 3; l++) {
for (let w = 0; w < 2; w++) {
const cx = ox + l * fx + w * rx;
const cz = oz + l * fz + w * rz;
groundCells.push({ x: cx, y: oy - 1, z: cz });
for (let h = 0; h <= 2; h++) clearCells.push({ x: cx, y: oy + h, z: cz });
}
}
for (const g of groundCells) {
const b = dim.getBlock(g);
if (!b || !b.isSolid) {
player.sendMessage("§c[Camping] §7Need a flat 2×3 patch of solid ground in front of you.");
return false;
}
}
for (const c of clearCells) {
const b = dim.getBlock(c);
if (!b) {
player.sendMessage("§c[Camping] §7Can't reach that area (chunk unloaded).");
return false;
}
if (!b.isAir && !b.isLiquid) {
player.sendMessage("§c[Camping] §7The space above the tent footprint isn't clear.");
return false;
}
}
const canvasCells = [];
for (let w = 0; w < 2; w++) {
// Back wall at l=0 (Y=0 and Y=1)
canvasCells.push({ x: ox + w * rx, y: oy + 0, z: oz + w * rz });
canvasCells.push({ x: ox + w * rx, y: oy + 1, z: oz + w * rz });
// Roof at l=1 and l=2 (Y=1)
canvasCells.push({ x: ox + 1 * fx + w * rx, y: oy + 1, z: oz + 1 * fz + w * rz });
canvasCells.push({ x: ox + 2 * fx + w * rx, y: oy + 1, z: oz + 2 * fz + w * rz });
}
for (const c of canvasCells) {
try {
dim.runCommand(`setblock ${c.x} ${c.y} ${c.z} ${TENT_BLOCK}`);
} catch (_) {}
}
const key = keyOf(ox, oy, oz, dim.id);
state.tents[key] = {
ownerId: player.id,
ownerName: player.name,
cells: canvasCells.map((c) => [c.x, c.y, c.z]),
};
saveState();
return true;
}
// ─── Hammock placement ──────────────────────────────────────
function isPostBlock(block) {
if (!block) return false;
const id = block.typeId;
return (
id.endsWith("_fence") ||
id.endsWith("_log") ||
id.endsWith("_wood") ||
id.includes("stripped_") ||
id.endsWith("_wall")
);
}
function findPartnerPost(dim, anchor) {
const candidates = [];
for (let dx = -6; dx <= 6; dx++) {
for (let dz = -6; dz <= 6; dz++) {
if (dx === 0 && dz === 0) continue;
const aligned = dx === 0 || dz === 0 || Math.abs(dx) === Math.abs(dz);
if (!aligned) continue;
const dist = Math.max(Math.abs(dx), Math.abs(dz));
if (dist < 3 || dist > 6) continue;
for (let dy = -1; dy <= 1; dy++) {
const pos = { x: anchor.x + dx, y: anchor.y + dy, z: anchor.z + dz };
let blk;
try { blk = dim.getBlock(pos); } catch (_) { continue; }
if (!blk || !isPostBlock(blk)) continue;
candidates.push({ pos, dist, dy });
}
}
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => (Math.abs(a.dy) - Math.abs(b.dy)) || (a.dist - b.dist));
return candidates[0].pos;
}
function computeHammockCells(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const dz = b.z - a.z;
const steps = Math.max(Math.abs(dx), Math.abs(dz));
const cells = [];
for (let t = 1; t < steps; t++) {
const cx = a.x + Math.round((dx * t) / steps);
const cz = a.z + Math.round((dz * t) / steps);
let cy = a.y + Math.round((dy * t) / steps);
const rel = t / steps;
if (steps >= 4 && rel > 0.25 && rel < 0.75) cy -= 1;
cells.push({ x: cx, y: cy, z: cz });
}
return cells;
}
function tryPlaceHammock(player, anchorBlock) {
const dim = player.dimension;
const a = {
x: anchorBlock.location.x,
y: anchorBlock.location.y,
z: anchorBlock.location.z,
};
const b = findPartnerPost(dim, a);
if (!b) {
player.sendMessage("§c[Camping] §7Need a second post 36 blocks away (straight line or diagonal, ±1 block in height).");
return false;
}
const cells = computeHammockCells(a, b);
for (const c of cells) {
let blk;
try { blk = dim.getBlock(c); } catch (_) { return false; }
if (!blk || (!blk.isAir && !blk.isLiquid)) {
player.sendMessage("§c[Camping] §7The space between the posts isn't clear.");
return false;
}
}
for (const c of cells) {
try { dim.runCommand(`setblock ${c.x} ${c.y} ${c.z} ${HAMMOCK_BLOCK}`); } catch (_) {}
}
const key = `${a.x},${a.y},${a.z}->${b.x},${b.y},${b.z},${dim.id}`;
state.hammocks[key] = {
ownerId: player.id,
ownerName: player.name,
anchorA: [a.x, a.y, a.z],
anchorB: [b.x, b.y, b.z],
cells: cells.map((c) => [c.x, c.y, c.z]),
};
saveState();
return true;
}
// ─── Item use handler ───────────────────────────────────────
world.afterEvents.itemUse.subscribe((event) => {
const player = event.source;
const stack = event.itemStack;
if (!stack || !player) return;
if (stack.typeId === TENT_ITEM) {
system.run(() => {
if (tryPlaceTent(player)) {
consumeOneOfType(player, TENT_ITEM);
player.sendMessage("§a[Camping] §7Tent pitched. Right-click the canvas to rest until dawn.");
}
});
} else if (stack.typeId === HAMMOCK_ITEM) {
system.run(() => {
const looking = player.getBlockFromViewDirection({ maxDistance: 6 });
const block = looking?.block;
if (!block || !isPostBlock(block)) {
player.sendMessage("§c[Camping] §7Aim at a fence, log, or wooden post to anchor the hammock.");
return;
}
if (tryPlaceHammock(player, block)) {
consumeOneOfType(player, HAMMOCK_ITEM);
player.sendMessage("§a[Camping] §7Hammock strung. Right-click the cloth to climb in.");
}
});
}
});
// ─── Interact: tent rest + hammock toggle ───────────────────
try {
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
const block = event.block;
if (!block) return;
if (block.typeId === TENT_BLOCK) {
event.cancel = true;
const player = event.player;
system.run(() => sleepInTent(player));
} else if (block.typeId === HAMMOCK_BLOCK) {
event.cancel = true;
const player = event.player;
const loc = block.location;
system.run(() => toggleHammock(player, loc));
}
});
} catch (e) {
console.warn(`[Camping] playerInteractWithBlock unavailable: ${e}`);
}
function sleepInTent(player) {
const tod = world.getTimeOfDay();
if (tod < 12500 && tod > 500) {
player.sendMessage("§e[Camping] §7It's still daylight — nothing to sleep off.");
return;
}
player.addEffect("regeneration", 200, { amplifier: 1, showParticles: false });
player.addEffect("saturation", 40, { amplifier: 0, showParticles: false });
world.setTimeOfDay(0);
player.sendMessage("§a[Camping] §7You rest until dawn. §8Spawn point unchanged.");
}
function toggleHammock(player, loc) {
if (player.hasTag(HAMMOCK_TAG)) {
player.removeTag(HAMMOCK_TAG);
player.sendMessage("§7[Camping] You climb out of the hammock.");
return;
}
player.addTag(HAMMOCK_TAG);
try {
player.teleport(
{ x: loc.x + 0.5, y: loc.y + 0.4, z: loc.z + 0.5 },
{ dimension: player.dimension }
);
} catch (_) {}
player.sendMessage("§a[Camping] §7You settle into the hammock. Wild creatures don't notice you. §8(Sneak to climb out.)");
}
// ─── Hammock upkeep loop: mob repulsion + sneak-exit ────────
system.runInterval(() => {
for (const player of world.getAllPlayers()) {
if (!player.hasTag(HAMMOCK_TAG)) continue;
if (player.isSneaking) {
player.removeTag(HAMMOCK_TAG);
player.sendMessage("§7[Camping] You climb out of the hammock.");
continue;
}
let hostiles = [];
try {
hostiles = player.dimension.getEntities({
families: ["monster"],
location: player.location,
maxDistance: 14,
});
} catch (_) {}
for (const m of hostiles) {
const dx = m.location.x - player.location.x;
const dy = m.location.y - player.location.y;
const dz = m.location.z - player.location.z;
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (d > 0.01 && d < 6) {
const scale = 9 / d;
const target = {
x: player.location.x + dx * scale,
y: m.location.y,
z: player.location.z + dz * scale,
};
try { m.tryTeleport(target, { checkForBlocks: true }); } catch (_) {}
}
}
}
}, 10);
// ─── Break cleanup: break one = pack up the whole structure ─
try {
world.beforeEvents.playerBreakBlock.subscribe((event) => {
const block = event.block;
if (!block) return;
const id = block.typeId;
if (id !== TENT_BLOCK && id !== HAMMOCK_BLOCK) return;
event.cancel = true;
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
const dimId = block.dimension.id;
const player = event.player;
if (id === TENT_BLOCK) {
system.run(() => dismantleTentAt(loc, dimId, player));
} else {
system.run(() => dismantleHammockAt(loc, dimId, player));
}
});
} catch (e) {
console.warn(`[Camping] playerBreakBlock unavailable: ${e}`);
}
function dismantleTentAt(loc, dimId, player) {
const dim = world.getDimension(dimId);
let matchedKey = null;
for (const [k, tent] of Object.entries(state.tents)) {
const parts = k.split(",");
if (parts[parts.length - 1] !== dimId) continue;
if (tent.cells.some(([x, y, z]) => x === loc.x && y === loc.y && z === loc.z)) {
matchedKey = k;
break;
}
}
if (!matchedKey) {
try { dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`); } catch (_) {}
return;
}
const tent = state.tents[matchedKey];
for (const [x, y, z] of tent.cells) {
try { dim.runCommand(`setblock ${x} ${y} ${z} air`); } catch (_) {}
}
delete state.tents[matchedKey];
saveState();
try {
dim.spawnItem(new ItemStack(TENT_ITEM, 1), { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 });
} catch (_) {}
if (player) player.sendMessage("§7[Camping] Tent packed up.");
}
function dismantleHammockAt(loc, dimId, player) {
const dim = world.getDimension(dimId);
let matchedKey = null;
for (const [k, h] of Object.entries(state.hammocks)) {
if (!k.endsWith("," + dimId)) continue;
if (h.cells.some(([x, y, z]) => x === loc.x && y === loc.y && z === loc.z)) {
matchedKey = k;
break;
}
}
if (!matchedKey) {
try { dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`); } catch (_) {}
return;
}
const h = state.hammocks[matchedKey];
for (const [x, y, z] of h.cells) {
try { dim.runCommand(`setblock ${x} ${y} ${z} air`); } catch (_) {}
}
delete state.hammocks[matchedKey];
saveState();
try {
dim.spawnItem(new ItemStack(HAMMOCK_ITEM, 1), { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 });
} catch (_) {}
if (player) player.sendMessage("§7[Camping] Hammock taken down.");
}
// ─── Boot ───────────────────────────────────────────────────
system.run(() => {
loadState();
world.sendMessage("§6[Camping] §7Camping Supplies loaded.");
});

View File

@@ -0,0 +1,5 @@
{
"format_version": [1, 1, 0],
"silverlabs:tent_canvas": { "sound": "cloth" },
"silverlabs:hammock_cloth": { "sound": "cloth" }
}

View File

@@ -0,0 +1,17 @@
{
"format_version": 2,
"header": {
"name": "Camping Supplies Resources",
"description": "Textures and lang for camping supplies (tent, hammock, canvas, cloth)",
"uuid": "36f12107-10c6-484c-a0f2-b5dd88cd5baa",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "resources",
"uuid": "c9ee429f-9374-4083-843b-4b195e8db130",
"version": [1, 0, 0]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
item.silverlabs:tent.name=Tent
item.silverlabs:hammock.name=Hammock
tile.silverlabs:tent_canvas.name=Tent Canvas
tile.silverlabs:hammock_cloth.name=Hammock Cloth

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

View File

@@ -0,0 +1,12 @@
{
"resource_pack_name": "camping_supplies_RP",
"texture_name": "atlas.items",
"texture_data": {
"tent_item": {
"textures": "textures/items/tent"
},
"hammock_item": {
"textures": "textures/items/hammock"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

View File

@@ -0,0 +1,14 @@
{
"resource_pack_name": "camping_supplies_RP",
"texture_name": "atlas.terrain",
"padding": 8,
"num_mip_levels": 4,
"texture_data": {
"tent_canvas": {
"textures": "textures/blocks/tent_canvas"
},
"hammock_cloth": {
"textures": "textures/blocks/hammock_cloth"
}
}
}

View File

@@ -32,6 +32,8 @@ services:
- ./keep-inventory-addon/keep_inventory_BP:/data/behavior_packs/keep_inventory_BP
- ./postal-service-addon/postal_service_BP:/data/behavior_packs/postal_service_BP
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP
- ./camping-supplies-addon/camping_supplies_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
restart: unless-stopped
networks:
- mc-network
@@ -59,6 +61,8 @@ services:
- ./keep-inventory-addon/keep_inventory_BP:/data/behavior_packs/keep_inventory_BP
- ./postal-service-addon/postal_service_BP:/data/behavior_packs/postal_service_BP
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP
- ./camping-supplies-addon/camping_supplies_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
restart: unless-stopped
networks:
- mc-network
@@ -91,6 +95,8 @@ services:
- ./keep-inventory-addon/keep_inventory_BP:/data/behavior_packs/keep_inventory_BP
- ./postal-service-addon/postal_service_BP:/data/behavior_packs/postal_service_BP
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP
- ./camping-supplies-addon/camping_supplies_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json
restart: unless-stopped
networks:
@@ -124,6 +130,8 @@ services:
- ./keep-inventory-addon/keep_inventory_BP:/data/behavior_packs/keep_inventory_BP
- ./postal-service-addon/postal_service_BP:/data/behavior_packs/postal_service_BP
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP
- ./camping-supplies-addon/camping_supplies_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json
restart: unless-stopped
networks: