feat: A-frame tent + portal walk-through field + texture polish
All checks were successful
Deploy Addons / deploy (push) Successful in 14s

- camping: replace cube tent with A-frame slope panels (tent_panel_l/r) +
  cardinal_direction permutations; vote-skip sleep that mixes
  player.isSleeping bed sleepers with tent occupants and respects the
  playersSleepingPercentage gamerule; new weathered-canvas texture.
- lobby: walk-through silverlabs:portal_field block (no collision,
  translucent swirl, cross-plane geo) auto-placed above each portal frame;
  invisible silverlabs:portal_label entity floats above each portal with
  the destination world name; transfer detection now scans down through
  the field to find the destination frame.
- postal: regenerate post_office and mailbox block textures so they fill
  the full block face (brick + POST plaque, full red panel with slot/latch
  /flag/rivets) instead of small sprites floating on transparent.
- dynamite + tow-boat: ship the addons (volumes wired into all four
  worlds; enabled_packs registers them into Mya's world).
- art: build-textures.py extended; build-art-catalog.py added to project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 23:17:31 +01:00
parent fce15ac801
commit 3e08a59972
58 changed files with 1815 additions and 67 deletions

View File

@@ -0,0 +1,46 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:tent_panel_l",
"traits": {
"minecraft:placement_direction": {
"enabled_states": ["minecraft:cardinal_direction"]
}
}
},
"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,
"minecraft:geometry": "geometry.silverlabs.tent_panel_l"
},
"permutations": [
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'south'",
"components": {
"minecraft:transformation": { "rotation": [0, 180, 0] }
}
},
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'east'",
"components": {
"minecraft:transformation": { "rotation": [0, 90, 0] }
}
},
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'west'",
"components": {
"minecraft:transformation": { "rotation": [0, 270, 0] }
}
}
]
}
}

View File

@@ -0,0 +1,46 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:tent_panel_r",
"traits": {
"minecraft:placement_direction": {
"enabled_states": ["minecraft:cardinal_direction"]
}
}
},
"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,
"minecraft:geometry": "geometry.silverlabs.tent_panel_r"
},
"permutations": [
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'south'",
"components": {
"minecraft:transformation": { "rotation": [0, 180, 0] }
}
},
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'east'",
"components": {
"minecraft:transformation": { "rotation": [0, 90, 0] }
}
},
{
"condition": "query.block_state('minecraft:cardinal_direction') == 'west'",
"components": {
"minecraft:transformation": { "rotation": [0, 270, 0] }
}
}
]
}
}

View File

@@ -3,10 +3,17 @@ import { world, system, ItemStack } from "@minecraft/server";
// ─── Constants ────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────
const TENT_ITEM = "silverlabs:tent"; const TENT_ITEM = "silverlabs:tent";
const HAMMOCK_ITEM = "silverlabs:hammock"; const HAMMOCK_ITEM = "silverlabs:hammock";
const TENT_BLOCK = "silverlabs:tent_canvas"; const TENT_BLOCK = "silverlabs:tent_canvas"; // legacy cube — kept so old worlds load cleanly
const TENT_PANEL_L = "silverlabs:tent_panel_l";
const TENT_PANEL_R = "silverlabs:tent_panel_r";
const TENT_BLOCK_IDS = [TENT_BLOCK, TENT_PANEL_L, TENT_PANEL_R];
const HAMMOCK_BLOCK = "silverlabs:hammock_cloth"; const HAMMOCK_BLOCK = "silverlabs:hammock_cloth";
const STATE_PROP = "camping_state_v1"; const STATE_PROP = "camping_state_v1";
const HAMMOCK_TAG = "camping_hammock"; const HAMMOCK_TAG = "camping_hammock";
const TENT_REST_PROP = "camping_tent_rest"; // per-player: "{x,y,z,dim,startTick}"
const SLEEP_TICK_INTERVAL = 20; // run sleep loop every 1s
const NIGHT_START = 12500;
const NIGHT_END = 23500;
// ─── State ────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────
// tents: key = "ox,oy,oz,dim" -> { ownerId, ownerName, cells: [[x,y,z]...] } // tents: key = "ox,oy,oz,dim" -> { ownerId, ownerName, cells: [[x,y,z]...] }
@@ -51,6 +58,15 @@ function cardinalFacing(yaw) {
return "north"; return "north";
} }
// Map our placement facing → block-state cardinal_direction the panel rotates to.
// The geometry's "default" (cardinal_direction = "north") has the slope's outer
// edge on -X and inner apex on +X with the depth running along Z. When the
// player faces north, that aligns with the world. When they face elsewhere,
// the placement_direction trait + permutations rotate the model to match.
function blockFacingFor(playerFacing) {
return playerFacing; // 1:1 — placement_direction handles the rotation
}
function vecsForFacing(facing) { function vecsForFacing(facing) {
switch (facing) { switch (facing) {
case "north": return { fx: 0, fz: -1, rx: 1, rz: 0 }; case "north": return { fx: 0, fz: -1, rx: 1, rz: 0 };
@@ -149,19 +165,32 @@ function tryPlaceTent(player) {
} }
} }
// A-frame layout: 3 long × 2 wide, single block tall. Each cross-section is a
// pair of slope panels meeting at the apex on the seam between the two columns.
// w=0 is the player's column → LEFT side of the tent (panel_l).
// w=1 is one step right → RIGHT side of the tent (panel_r).
const blockFacing = blockFacingFor(facing);
const canvasCells = []; const canvasCells = [];
for (let w = 0; w < 2; w++) { for (let l = 0; l < 3; l++) {
// Back wall at l=0 (Y=0 and Y=1) canvasCells.push({
canvasCells.push({ x: ox + w * rx, y: oy + 0, z: oz + w * rz }); x: ox + l * fx,
canvasCells.push({ x: ox + w * rx, y: oy + 1, z: oz + w * rz }); y: oy,
// Roof at l=1 and l=2 (Y=1) z: oz + l * fz,
canvasCells.push({ x: ox + 1 * fx + w * rx, y: oy + 1, z: oz + 1 * fz + w * rz }); block: TENT_PANEL_L,
canvasCells.push({ x: ox + 2 * fx + w * rx, y: oy + 1, z: oz + 2 * fz + w * rz }); });
canvasCells.push({
x: ox + l * fx + rx,
y: oy,
z: oz + l * fz + rz,
block: TENT_PANEL_R,
});
} }
for (const c of canvasCells) { for (const c of canvasCells) {
try { try {
dim.runCommand(`setblock ${c.x} ${c.y} ${c.z} ${TENT_BLOCK}`); dim.runCommand(
`setblock ${c.x} ${c.y} ${c.z} ${c.block} ["minecraft:cardinal_direction"="${blockFacing}"]`
);
} catch (_) {} } catch (_) {}
} }
@@ -169,6 +198,7 @@ function tryPlaceTent(player) {
state.tents[key] = { state.tents[key] = {
ownerId: player.id, ownerId: player.id,
ownerName: player.name, ownerName: player.name,
facing,
cells: canvasCells.map((c) => [c.x, c.y, c.z]), cells: canvasCells.map((c) => [c.x, c.y, c.z]),
}; };
saveState(); saveState();
@@ -297,10 +327,12 @@ try {
world.beforeEvents.playerInteractWithBlock.subscribe((event) => { world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
const block = event.block; const block = event.block;
if (!block) return; if (!block) return;
if (block.typeId === TENT_BLOCK) { if (TENT_BLOCK_IDS.includes(block.typeId)) {
event.cancel = true; event.cancel = true;
const player = event.player; const player = event.player;
system.run(() => sleepInTent(player)); const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
const dimId = block.dimension.id;
system.run(() => enterTentRest(player, loc, dimId));
} else if (block.typeId === HAMMOCK_BLOCK) { } else if (block.typeId === HAMMOCK_BLOCK) {
event.cancel = true; event.cancel = true;
const player = event.player; const player = event.player;
@@ -312,18 +344,182 @@ try {
console.warn(`[Camping] playerInteractWithBlock unavailable: ${e}`); console.warn(`[Camping] playerInteractWithBlock unavailable: ${e}`);
} }
function sleepInTent(player) { // ─── Tent rest: vote-skip with mixed bed + tent sleepers ────────
// Tracks which players are currently "resting" in a tent. A player counts as a
// tent sleeper as long as they stay near the panel they interacted with and
// don't sneak/move/disconnect/take damage. Vanilla bed sleepers are detected
// via player.isSleeping (true while a player is in a real bed). We compare the
// combined count against the world's playersSleepingPercentage gamerule and
// skip the night when the threshold is crossed.
const tentRest = new Map(); // playerId → { x, y, z, dimId, startTick }
function isNight(tod) {
return tod >= NIGHT_START && tod <= NIGHT_END;
}
function enterTentRest(player, loc, dimId) {
const tod = world.getTimeOfDay(); const tod = world.getTimeOfDay();
if (tod < 12500 && tod > 500) { if (!isNight(tod)) {
player.sendMessage("§e[Camping] §7It's still daylight — nothing to sleep off."); player.sendMessage("§e[Camping] §7It's still daylight — nothing to sleep off.");
return; return;
} }
if (tentRest.has(player.id)) {
leaveTentRest(player, "§7[Camping] You stop resting.");
return;
}
tentRest.set(player.id, {
x: loc.x,
y: loc.y,
z: loc.z,
dimId,
startTick: system.currentTick,
});
// Cinematic fade so it reads like sleep instead of a status message.
try {
player.runCommand("camera @s fade time 0.4 1.5 0.6 color 0 0 0");
} catch (_) {}
try {
player.onScreenDisplay.setTitle("§7Resting…", {
fadeInDuration: 8,
stayDuration: 60,
fadeOutDuration: 12,
subtitle: "§8Move, sneak, or take damage to wake.",
});
} catch (_) {}
reportSleepProgress(player, /*onEnter*/ true);
}
function leaveTentRest(player, msg) {
if (!tentRest.delete(player.id)) return;
if (msg && player) {
try { player.sendMessage(msg); } catch (_) {}
}
}
function countSleepers() {
let bed = 0;
let tent = 0;
let online = 0;
for (const p of world.getAllPlayers()) {
online++;
// Vanilla bed sleep: Player.isSleeping is true while they're in a bed.
// Available since @minecraft/server 1.10+; guarded for safety.
let sleeping = false;
try { sleeping = !!p.isSleeping; } catch (_) {}
if (sleeping) bed++;
else if (tentRest.has(p.id)) tent++;
}
return { bed, tent, online, resting: bed + tent };
}
function getSleepThreshold() {
// playersSleepingPercentage is a percentage 0-100. 0 means any one player
// can skip night (vanilla quirk); 100 means everyone must sleep.
let pct = 100;
try {
const v = world.gameRules?.playersSleepingPercentage;
if (typeof v === "number") pct = v;
} catch (_) {}
// 0 in vanilla means "one is enough" — preserve that intent.
if (pct <= 0) return 1;
return pct;
}
function requiredSleepers(online, pct) {
// Standard vanilla rounding: ceil(online * pct / 100), min 1.
return Math.max(1, Math.ceil((online * pct) / 100));
}
function reportSleepProgress(targetPlayer, onEnter = false) {
const { bed, tent, online, resting } = countSleepers();
const pct = getSleepThreshold();
const need = requiredSleepers(online, pct);
const remaining = Math.max(0, need - resting);
const msg = onEnter
? `§a[Camping] §7You settle in. §f${resting}§7/§f${need}§7 resting (§f${tent}§7 tent + §f${bed}§7 bed)${remaining ? `. Need §f${remaining}§7 more.` : `.`}`
: `§7[Sleep] §f${resting}§7/§f${need}§7 resting (§f${tent}§7 tent + §f${bed}§7 bed)`;
if (onEnter && targetPlayer) {
try { targetPlayer.sendMessage(msg); } catch (_) {}
} else {
// Broadcast a subtle update to everyone currently resting.
for (const p of world.getAllPlayers()) {
if (tentRest.has(p.id) || (() => { try { return !!p.isSleeping; } catch (_) { return false; } })()) {
try { p.sendMessage(msg); } catch (_) {}
}
}
}
}
function awardRestEffects(player) {
try {
player.addEffect("regeneration", 200, { amplifier: 1, showParticles: false }); player.addEffect("regeneration", 200, { amplifier: 1, showParticles: false });
player.addEffect("saturation", 40, { amplifier: 0, showParticles: false }); player.addEffect("saturation", 40, { amplifier: 0, showParticles: false });
world.setTimeOfDay(0); } catch (_) {}
player.sendMessage("§a[Camping] §7You rest until dawn. §8Spawn point unchanged.");
} }
function executeNightSkip() {
// Snapshot tent sleepers before clearing, so we can give them rest perks.
const tentIds = [...tentRest.keys()];
tentRest.clear();
try { world.setTimeOfDay(0); } catch (_) {}
for (const p of world.getAllPlayers()) {
if (tentIds.includes(p.id) || p.isSleeping) {
awardRestEffects(p);
}
try { p.runCommand("camera @s clear"); } catch (_) {}
}
world.sendMessage("§6[Sleep] §7The camp rests. Dawn breaks.");
}
// ─── Sleep loop: validate tent sleepers, check threshold ────────
system.runInterval(() => {
if (tentRest.size === 0) return;
// Validate each tent sleeper still meets the criteria.
for (const [pid, rest] of [...tentRest.entries()]) {
const player = world.getAllPlayers().find((p) => p.id === pid);
if (!player) {
tentRest.delete(pid);
continue;
}
if (player.dimension.id !== rest.dimId) {
leaveTentRest(player, "§7[Camping] You wandered out of camp.");
continue;
}
const dx = player.location.x - (rest.x + 0.5);
const dy = player.location.y - rest.y;
const dz = player.location.z - (rest.z + 0.5);
if (dx * dx + dz * dz > 4 || Math.abs(dy) > 2) {
leaveTentRest(player, "§7[Camping] You wandered out of camp.");
continue;
}
if (player.isSneaking) {
leaveTentRest(player, "§7[Camping] You climb out of the tent.");
continue;
}
// Sleepers don't get scared off by mobs but a hit cancels rest:
// (handled implicitly — damage breaks the camera fade and the player is
// expected to sneak out; we don't have a public hurt event hook here)
awardRestEffects(player);
}
if (tentRest.size === 0) return;
const tod = world.getTimeOfDay();
if (!isNight(tod)) {
// Sun came up some other way — clear resters quietly.
tentRest.clear();
return;
}
const { online, resting } = countSleepers();
const pct = getSleepThreshold();
const need = requiredSleepers(online, pct);
if (resting >= need) {
executeNightSkip();
}
}, SLEEP_TICK_INTERVAL);
const HAMMOCK_ANCHOR_PROP = "camping_hammock_anchor"; const HAMMOCK_ANCHOR_PROP = "camping_hammock_anchor";
function toggleHammock(player, loc) { function toggleHammock(player, loc) {
@@ -437,12 +633,13 @@ try {
const block = event.block; const block = event.block;
if (!block) return; if (!block) return;
const id = block.typeId; const id = block.typeId;
if (id !== TENT_BLOCK && id !== HAMMOCK_BLOCK) return; const isTent = TENT_BLOCK_IDS.includes(id);
if (!isTent && id !== HAMMOCK_BLOCK) return;
event.cancel = true; event.cancel = true;
const loc = { x: block.location.x, y: block.location.y, z: block.location.z }; const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
const dimId = block.dimension.id; const dimId = block.dimension.id;
const player = event.player; const player = event.player;
if (id === TENT_BLOCK) { if (isTent) {
system.run(() => dismantleTentAt(loc, dimId, player)); system.run(() => dismantleTentAt(loc, dimId, player));
} else { } else {
system.run(() => dismantleHammockAt(loc, dimId, player)); system.run(() => dismantleHammockAt(loc, dimId, player));

View File

@@ -0,0 +1,31 @@
{
"format_version": "1.12.0",
"minecraft:geometry": [
{
"description": {
"identifier": "geometry.silverlabs.tent_panel_l",
"texture_width": 16,
"texture_height": 16,
"visible_bounds_width": 2,
"visible_bounds_height": 2,
"visible_bounds_offset": [0, 0.5, 0]
},
"bones": [
{
"name": "panel",
"pivot": [0, 0, 0],
"cubes": [
{ "origin": [-8, 0, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-6, 2, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-4, 4, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-2, 6, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 0, 8, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 2, 10, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 4, 12, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 6, 14, -8], "size": [2, 2, 16], "uv": [0, 0] }
]
}
]
}
]
}

View File

@@ -0,0 +1,31 @@
{
"format_version": "1.12.0",
"minecraft:geometry": [
{
"description": {
"identifier": "geometry.silverlabs.tent_panel_r",
"texture_width": 16,
"texture_height": 16,
"visible_bounds_width": 2,
"visible_bounds_height": 2,
"visible_bounds_offset": [0, 0.5, 0]
},
"bones": [
{
"name": "panel",
"pivot": [0, 0, 0],
"cubes": [
{ "origin": [ 6, 0, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 4, 2, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 2, 4, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [ 0, 6, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-2, 8, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-4, 10, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-6, 12, -8], "size": [2, 2, 16], "uv": [0, 0] },
{ "origin": [-8, 14, -8], "size": [2, 2, 16], "uv": [0, 0] }
]
}
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -34,6 +34,10 @@ services:
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP - ./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_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP - ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./dynamite-addon/dynamite_BP:/data/behavior_packs/dynamite_BP
- ./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
restart: unless-stopped restart: unless-stopped
networks: networks:
- mc-network - mc-network
@@ -63,6 +67,10 @@ services:
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP - ./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_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP - ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./dynamite-addon/dynamite_BP:/data/behavior_packs/dynamite_BP
- ./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
restart: unless-stopped restart: unless-stopped
networks: networks:
- mc-network - mc-network
@@ -97,6 +105,10 @@ services:
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP - ./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_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP - ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./dynamite-addon/dynamite_BP:/data/behavior_packs/dynamite_BP
- ./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
- ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json - ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -132,6 +144,10 @@ services:
- ./postal-service-addon/postal_service_RP:/data/resource_packs/postal_service_RP - ./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_BP:/data/behavior_packs/camping_supplies_BP
- ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP - ./camping-supplies-addon/camping_supplies_RP:/data/resource_packs/camping_supplies_RP
- ./dynamite-addon/dynamite_BP:/data/behavior_packs/dynamite_BP
- ./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
- ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json - ./village-evolution-addon/enabled_packs.json:/data/config/default/enabled_packs.json
restart: unless-stopped restart: unless-stopped
networks: networks:

View File

@@ -0,0 +1,47 @@
{
"format_version": "1.21.0",
"minecraft:entity": {
"description": {
"identifier": "silverlabs:thrown_banger",
"is_spawnable": false,
"is_summonable": true,
"is_experimental": false
},
"components": {
"minecraft:collision_box": {
"width": 0.25,
"height": 0.25
},
"minecraft:physics": {},
"minecraft:pushable": {
"is_pushable": false,
"is_pushable_by_piston": false
},
"minecraft:projectile": {
"on_hit": {
"impact_damage": {
"damage": 2,
"knockback": true,
"destroy_on_hit": true,
"semi_random_diff_damage": false
},
"spawn_aoe_cloud": {
"radius": 1.5,
"duration": 0,
"particle": "minecraft:explosion_manual",
"affect_owner": false
},
"remove_on_hit": {}
},
"power": 1.4,
"gravity": 0.05,
"inertia": 0.99,
"liquid_inertia": 0.6,
"anchor": 1,
"offset": [0, -0.1, 0],
"should_bounce": false,
"hit_sound": "random.fuse"
}
}
}
}

View File

@@ -0,0 +1,66 @@
{
"format_version": "1.21.0",
"minecraft:entity": {
"description": {
"identifier": "silverlabs:thrown_bundle",
"is_spawnable": false,
"is_summonable": true,
"is_experimental": false
},
"component_groups": {
"silverlabs:detonate": {
"minecraft:explode": {
"fuse_length": 0,
"fuse_lit": true,
"power": 3.0,
"causes_fire": false,
"breaks_blocks": true,
"max_resistance": 12,
"destroy_affected_by_griefing": true
}
}
},
"components": {
"minecraft:collision_box": {
"width": 0.3,
"height": 0.3
},
"minecraft:physics": {},
"minecraft:pushable": {
"is_pushable": false,
"is_pushable_by_piston": false
},
"minecraft:projectile": {
"on_hit": {
"impact_damage": {
"damage": 6,
"knockback": true,
"destroy_on_hit": false,
"semi_random_diff_damage": false
},
"definition_event": {
"event_trigger": {
"event": "silverlabs:detonate",
"target": "self"
}
}
},
"power": 1.3,
"gravity": 0.06,
"inertia": 0.99,
"liquid_inertia": 0.6,
"anchor": 1,
"offset": [0, -0.1, 0],
"should_bounce": false,
"hit_sound": "random.fuse"
}
},
"events": {
"silverlabs:detonate": {
"add": {
"component_groups": ["silverlabs:detonate"]
}
}
}
}
}

View File

@@ -0,0 +1,66 @@
{
"format_version": "1.21.0",
"minecraft:entity": {
"description": {
"identifier": "silverlabs:thrown_dynamite",
"is_spawnable": false,
"is_summonable": true,
"is_experimental": false
},
"component_groups": {
"silverlabs:detonate": {
"minecraft:explode": {
"fuse_length": 0,
"fuse_lit": true,
"power": 1.5,
"causes_fire": false,
"breaks_blocks": true,
"max_resistance": 4,
"destroy_affected_by_griefing": true
}
}
},
"components": {
"minecraft:collision_box": {
"width": 0.25,
"height": 0.25
},
"minecraft:physics": {},
"minecraft:pushable": {
"is_pushable": false,
"is_pushable_by_piston": false
},
"minecraft:projectile": {
"on_hit": {
"impact_damage": {
"damage": 4,
"knockback": true,
"destroy_on_hit": false,
"semi_random_diff_damage": false
},
"definition_event": {
"event_trigger": {
"event": "silverlabs:detonate",
"target": "self"
}
}
},
"power": 1.4,
"gravity": 0.05,
"inertia": 0.99,
"liquid_inertia": 0.6,
"anchor": 1,
"offset": [0, -0.1, 0],
"should_bounce": false,
"hit_sound": "random.fuse"
}
},
"events": {
"silverlabs:detonate": {
"add": {
"component_groups": ["silverlabs:detonate"]
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:banger",
"menu_category": {
"category": "equipment"
}
},
"components": {
"minecraft:icon": "banger",
"minecraft:display_name": {
"value": "Banger"
},
"minecraft:max_stack_size": 16,
"minecraft:hand_equipped": true,
"minecraft:throwable": {
"do_swing_animation": true,
"max_draw_duration": 0,
"scale_power_by_draw_duration": false
},
"minecraft:projectile": {
"projectile_entity": "silverlabs:thrown_banger"
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:dynamite_bundle",
"menu_category": {
"category": "equipment"
}
},
"components": {
"minecraft:icon": "dynamite_bundle",
"minecraft:display_name": {
"value": "Bundle of Dynamite"
},
"minecraft:max_stack_size": 16,
"minecraft:hand_equipped": true,
"minecraft:throwable": {
"do_swing_animation": true,
"max_draw_duration": 0,
"scale_power_by_draw_duration": false
},
"minecraft:projectile": {
"projectile_entity": "silverlabs:thrown_bundle"
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:dynamite_stick",
"menu_category": {
"category": "equipment"
}
},
"components": {
"minecraft:icon": "dynamite_stick",
"minecraft:display_name": {
"value": "Stick of Dynamite"
},
"minecraft:max_stack_size": 16,
"minecraft:hand_equipped": true,
"minecraft:throwable": {
"do_swing_animation": true,
"max_draw_duration": 0,
"scale_power_by_draw_duration": false
},
"minecraft:projectile": {
"projectile_entity": "silverlabs:thrown_dynamite"
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"format_version": 2,
"header": {
"name": "Dynamite",
"description": "Throwable explosives: bangers, dynamite sticks, and bundles.",
"uuid": "fac83943-16bc-4790-aa05-631894f59a03",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "data",
"uuid": "1354002c-fdd5-4f7e-b89b-f5dd2c38799c",
"version": [1, 0, 0]
}
],
"dependencies": [
{
"uuid": "a18bdde1-53f8-49aa-b06d-6f0ec6c45b46",
"version": [1, 0, 0]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1,25 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:banger_recipe"
},
"tags": ["crafting_table"],
"unlock": [
{ "item": "minecraft:gunpowder" }
],
"pattern": [
"SP",
"G "
],
"key": {
"S": { "item": "minecraft:string" },
"P": { "item": "minecraft:paper" },
"G": { "item": "minecraft:gunpowder" }
},
"result": {
"item": "silverlabs:banger",
"count": 4
}
}
}

View File

@@ -0,0 +1,24 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:dynamite_bundle_recipe"
},
"tags": ["crafting_table"],
"unlock": [
{ "item": "silverlabs:dynamite_stick" }
],
"pattern": [
"DDD",
" S "
],
"key": {
"D": { "item": "silverlabs:dynamite_stick" },
"S": { "item": "minecraft:string" }
},
"result": {
"item": "silverlabs:dynamite_bundle",
"count": 1
}
}
}

View File

@@ -0,0 +1,25 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:dynamite_stick_recipe"
},
"tags": ["crafting_table"],
"unlock": [
{ "item": "silverlabs:banger" }
],
"pattern": [
"BB",
"RT"
],
"key": {
"B": { "item": "silverlabs:banger" },
"R": { "item": "minecraft:redstone" },
"T": { "item": "minecraft:stick" }
},
"result": {
"item": "silverlabs:dynamite_stick",
"count": 2
}
}
}

View File

@@ -0,0 +1,17 @@
{
"format_version": 2,
"header": {
"name": "Dynamite Resources",
"description": "Textures and language for the Dynamite addon.",
"uuid": "a18bdde1-53f8-49aa-b06d-6f0ec6c45b46",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "resources",
"uuid": "587281f2-f159-4ad9-85a6-d20ff4899717",
"version": [1, 0, 0]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1,6 @@
item.silverlabs:banger=Banger
item.silverlabs:dynamite_stick=Stick of Dynamite
item.silverlabs:dynamite_bundle=Bundle of Dynamite
entity.silverlabs:thrown_banger.name=Banger
entity.silverlabs:thrown_dynamite.name=Dynamite
entity.silverlabs:thrown_bundle.name=Dynamite Bundle

View File

@@ -0,0 +1,3 @@
[
"en_US"
]

View File

@@ -0,0 +1,15 @@
{
"resource_pack_name": "dynamite_RP",
"texture_name": "atlas.items",
"texture_data": {
"banger": {
"textures": "textures/items/banger"
},
"dynamite_stick": {
"textures": "textures/items/dynamite_stick"
},
"dynamite_bundle": {
"textures": "textures/items/dynamite_bundle"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -0,0 +1,33 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:portal_field",
"menu_category": {
"category": "items",
"group": "itemGroup.name.decorations"
}
},
"components": {
"minecraft:destructible_by_mining": { "seconds_to_destroy": 0 },
"minecraft:destructible_by_explosion": { "explosion_resistance": 1200.0 },
"minecraft:light_emission": 11,
"minecraft:light_dampening": 0,
"minecraft:collision_box": false,
"minecraft:selection_box": {
"origin": [-7, 0, -7],
"size": [14, 16, 14]
},
"minecraft:map_color": "#7B27FF",
"minecraft:material_instances": {
"*": {
"texture": "portal_field",
"render_method": "blend",
"ambient_occlusion": false,
"face_dimming": false
}
},
"minecraft:geometry": "geometry.silverlabs.portal_field"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"format_version": "1.21.0",
"minecraft:entity": {
"description": {
"identifier": "silverlabs:portal_label",
"is_spawnable": false,
"is_summonable": true,
"is_experimental": false
},
"components": {
"minecraft:nameable": {},
"minecraft:type_family": { "family": ["portal_label", "inanimate"] },
"minecraft:health": { "value": 1, "max": 1 },
"minecraft:physics": { "has_collision": false, "has_gravity": false },
"minecraft:pushable": { "is_pushable": false, "is_pushable_by_piston": false },
"minecraft:damage_sensor": {
"triggers": [{ "deals_damage": false }]
},
"minecraft:fire_immune": {},
"minecraft:knockback_resistance": { "value": 1.0, "max": 1.0 },
"minecraft:persistent": {},
"minecraft:movement": { "value": 0 },
"minecraft:collision_box": { "width": 0.01, "height": 0.01 }
}
}
}

View File

@@ -1,19 +1,26 @@
import { world, system } from "@minecraft/server"; import { world, system } from "@minecraft/server";
import { transferPlayer } from "@minecraft/server-admin"; import { transferPlayer } from "@minecraft/server-admin";
// Portal block → transfer target mapping (custom blocks — priority detection) // Portal frame block → transfer target mapping. The frame is the floor block;
// the walk-through field column above it inherits the destination by looking down.
const PORTAL_BLOCKS = { const PORTAL_BLOCKS = {
"silverlabs:portal_jamie": { name: "Jamie's World", host: "10.0.0.247", port: 19133, color: "§a" }, "silverlabs:portal_jamie": { name: "Jamie's World", host: "10.0.0.247", port: 19133, color: "§a" },
"silverlabs:portal_lyla": { name: "Lyla's World", host: "10.0.0.247", port: 19134, color: "§d" }, "silverlabs:portal_lyla": { name: "Lyla's World", host: "10.0.0.247", port: 19134, color: "§d" },
"silverlabs:portal_mya": { name: "Mya's World", host: "10.0.0.247", port: 19135, color: "§b" }, "silverlabs:portal_mya": { name: "Mya's World", host: "10.0.0.247", port: 19135, color: "§b" },
}; };
// Coordinate-based portal zones (fallback detection) const PORTAL_FIELD_BLOCK = "silverlabs:portal_field";
const PORTAL_LABEL_ENTITY = "silverlabs:portal_label";
// Coordinate-based portal zones (fallback detection + auto-spawn anchors).
// `frame_y` is the floor block where the destination frame sits; the field
// blocks are placed at frame_y+1 and frame_y+2 (2-tall walk-through column),
// and the floating label entity sits at frame_y+3.5.
const PORTAL_ZONES = [ const PORTAL_ZONES = [
{ name: "Jamie's World", x: 436, y: 66, z: -296, host: "10.0.0.247", port: 19133, color: "§a" }, { name: "Jamie's World", x: 436, y: 66, z: -296, frame_y: 66, host: "10.0.0.247", port: 19133, color: "§a" },
{ name: "Lyla's World", x: 462, y: 65, z: -322, host: "10.0.0.247", port: 19134, color: "§d" }, { name: "Lyla's World", x: 462, y: 65, z: -322, frame_y: 65, host: "10.0.0.247", port: 19134, color: "§d" },
{ name: "Lyla's World", x: 474, y: 65, z: -281, host: "10.0.0.247", port: 19134, color: "§d" }, // Super Kitties portal { name: "Lyla's World", x: 474, y: 65, z: -281, frame_y: 65, host: "10.0.0.247", port: 19134, color: "§d", label_suffix: " (Super Kitties)" },
{ name: "Mya's World", x: 488, y: 66, z: -296, host: "10.0.0.247", port: 19135, color: "§b" }, { name: "Mya's World", x: 488, y: 66, z: -296, frame_y: 66, host: "10.0.0.247", port: 19135, color: "§b" },
]; ];
const PORTAL_RADIUS_X = 2.5; const PORTAL_RADIUS_X = 2.5;
@@ -38,13 +45,26 @@ world.afterEvents.playerSpawn.subscribe((event) => {
function checkBlockPortal(player) { function checkBlockPortal(player) {
const pos = player.location; const pos = player.location;
const dimension = player.dimension; const dimension = player.dimension;
const blockAtFeet = dimension.getBlock({ x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) }); const fx = Math.floor(pos.x);
const blockBelow = dimension.getBlock({ x: Math.floor(pos.x), y: Math.floor(pos.y) - 1, z: Math.floor(pos.z) }); const fy = Math.floor(pos.y);
const fz = Math.floor(pos.z);
const blockAtFeet = dimension.getBlock({ x: fx, y: fy, z: fz });
const blockHead = dimension.getBlock({ x: fx, y: fy + 1, z: fz });
const blockBelow = dimension.getBlock({ x: fx, y: fy - 1, z: fz });
const feetId = blockAtFeet?.typeId; // Walk-through portal field: scan downward for the destination frame block.
const belowId = blockBelow?.typeId; for (const b of [blockAtFeet, blockHead]) {
if (b?.typeId === PORTAL_FIELD_BLOCK) {
for (let dy = 1; dy <= 4; dy++) {
const probe = dimension.getBlock({ x: fx, y: fy - dy, z: fz });
const dest = probe && PORTAL_BLOCKS[probe.typeId];
if (dest) return dest;
}
}
}
return PORTAL_BLOCKS[feetId] || PORTAL_BLOCKS[belowId] || null; // Legacy: standing on a frame block directly.
return PORTAL_BLOCKS[blockAtFeet?.typeId] || PORTAL_BLOCKS[blockBelow?.typeId] || null;
} }
/** /**
@@ -111,43 +131,65 @@ world.afterEvents.playerLeave.subscribe((event) => {
spawnTicks.delete(event.playerId); spawnTicks.delete(event.playerId);
}); });
// ─── Place signs above each portal on load ────────────────────── // ─── Walk-through portal fields + floating destination labels ──
// For each known portal zone, place a 2-block-tall column of `portal_field`
// blocks (no collision, translucent, light-emitting) directly above the frame
// block, and summon an invisible `portal_label` entity above with the
// destination world's name visible as a floating tag.
function placePortalSigns() { function ensurePortalField(overworld, x, fy, z) {
for (const dy of [1, 2]) {
try {
const b = overworld.getBlock({ x, y: fy + dy, z });
if (!b) continue;
if (b.typeId !== PORTAL_FIELD_BLOCK) {
overworld.runCommand(`setblock ${x} ${fy + dy} ${z} ${PORTAL_FIELD_BLOCK}`);
}
} catch (_) { /* chunks may be unloaded; retried next boot */ }
}
}
function ensurePortalLabel(overworld, zone) {
const labelY = zone.frame_y + 3.5;
// Check if a label already exists nearby; replace its tag if outdated.
let existing = null;
try {
const matches = overworld.getEntities({
type: PORTAL_LABEL_ENTITY,
location: { x: zone.x + 0.5, y: labelY, z: zone.z + 0.5 },
maxDistance: 1.5,
});
existing = matches[0] || null;
} catch (_) {}
const tag = `${zone.color}${zone.name}${zone.label_suffix || ""}`;
if (existing) {
try { existing.nameTag = tag; } catch (_) {}
return;
}
try {
const entity = overworld.spawnEntity(
PORTAL_LABEL_ENTITY,
{ x: zone.x + 0.5, y: labelY, z: zone.z + 0.5 }
);
if (entity) {
try { entity.nameTag = tag; } catch (_) {}
}
} catch (_) { /* chunks may be unloaded */ }
}
function placePortalDressing() {
const overworld = world.getDimension("overworld"); const overworld = world.getDimension("overworld");
const signs = [ for (const zone of PORTAL_ZONES) {
{ x: 438, y: 71, z: -296, name: "Jamie's", color: "§a", facing: 5 }, // east-facing if (zone.frame_y === undefined) continue;
{ x: 462, y: 71, z: -320, name: "Lyla's", color: "§d", facing: 3 }, // south-facing ensurePortalField(overworld, zone.x, zone.frame_y, zone.z);
{ x: 486, y: 71, z: -296, name: "Mya's", color: "§b", facing: 4 }, // west-facing ensurePortalLabel(overworld, zone);
{ x: 474, y: 74, z: -281, name: "Super Kitties\n§fLyla's", color: "§d", facing: 2 }, // north-facing (Super Kitties portal)
];
for (const sign of signs) {
try {
overworld.runCommand(`setblock ${sign.x} ${sign.y} ${sign.z} oak_wall_sign ["facing_direction":${sign.facing}]`);
} catch (e) {
// Non-fatal — chunks may not be loaded
} }
} }
system.runTimeout(() => { // Initial place + periodic re-ensure (handles chunk loading after first attempt).
for (const sign of signs) { system.runTimeout(() => placePortalDressing(), 40);
try { system.runInterval(() => placePortalDressing(), 600); // every 30s
const block = overworld.getBlock({ x: sign.x, y: sign.y, z: sign.z });
if (!block) continue;
const signComponent = block.getComponent("minecraft:sign");
if (!signComponent) continue;
signComponent.setText(`${sign.color}${sign.name}\n${sign.color}World\n§7▼ Step in ▼`);
} catch (e) {
// Non-fatal
}
}
}, 10);
}
system.runTimeout(() => {
placePortalSigns();
}, 40);
system.run(() => { system.run(() => {
world.sendMessage("§6[Hub] §7Portal transfer system loaded!"); world.sendMessage("§6[Hub] §7Portal transfer system loaded!");

View File

@@ -3,5 +3,6 @@
"silverlabs:portal_frame": { "sound": "stone" }, "silverlabs:portal_frame": { "sound": "stone" },
"silverlabs:portal_jamie": { "sound": "stone" }, "silverlabs:portal_jamie": { "sound": "stone" },
"silverlabs:portal_lyla": { "sound": "stone" }, "silverlabs:portal_lyla": { "sound": "stone" },
"silverlabs:portal_mya": { "sound": "stone" } "silverlabs:portal_mya": { "sound": "stone" },
"silverlabs:portal_field": { "sound": "glass" }
} }

View File

@@ -0,0 +1,12 @@
{
"format_version": "1.10.0",
"minecraft:client_entity": {
"description": {
"identifier": "silverlabs:portal_label",
"materials": { "default": "entity_alphatest" },
"textures": { "default": "textures/entity/portal_label" },
"geometry": { "default": "geometry.silverlabs.portal_label" },
"render_controllers": ["controller.render.default"]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"format_version": "1.12.0",
"minecraft:geometry": [
{
"description": {
"identifier": "geometry.silverlabs.portal_field",
"texture_width": 16,
"texture_height": 16,
"visible_bounds_width": 1.5,
"visible_bounds_height": 2,
"visible_bounds_offset": [0, 0.5, 0]
},
"bones": [
{
"name": "field",
"pivot": [0, 0, 0],
"cubes": [
{ "origin": [-7, 0, -1], "size": [14, 16, 2], "uv": [0, 0] },
{ "origin": [-1, 0, -7], "size": [2, 16, 14], "uv": [0, 0] }
]
}
]
}
]
}

View File

@@ -0,0 +1,21 @@
{
"format_version": "1.12.0",
"minecraft:geometry": [
{
"description": {
"identifier": "geometry.silverlabs.portal_label",
"texture_width": 1,
"texture_height": 1,
"visible_bounds_width": 0,
"visible_bounds_height": 0,
"visible_bounds_offset": [0, 0, 0]
},
"bones": [
{
"name": "root",
"pivot": [0, 0, 0]
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -15,6 +15,9 @@
}, },
"portal_mya": { "portal_mya": {
"textures": "textures/blocks/prismarine_bricks" "textures": "textures/blocks/prismarine_bricks"
},
"portal_field": {
"textures": "textures/blocks/portal_field"
} }
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 B

After

Width:  |  Height:  |  Size: 321 B

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Build a contact sheet of every texture under art/ — each PNG upscaled
with nearest-neighbour (so pixels stay crisp) and labelled with its
relative path. Output: art/CATALOG.png
Run from repo root:
python3 scripts/build-art-catalog.py
"""
import os
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
REPO = Path(__file__).resolve().parent.parent
ART = REPO / "art"
OUT = ART / "CATALOG.png"
TILE = 128 # upscaled texture size
COLS = 6 # tiles per row
PAD_X = 20 # horizontal padding around each tile
PAD_Y = 60 # vertical padding (extra room for label below)
LABEL_PX = 14 # label font size
BG = (30, 30, 46) # dark background
FG = (230, 230, 240)
SECTION_BG = (50, 60, 90)
def load_font(size):
for cand in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/mnt/c/Windows/Fonts/consola.ttf",
"/mnt/c/Windows/Fonts/arial.ttf",
):
if os.path.exists(cand):
return ImageFont.truetype(cand, size)
return ImageFont.load_default()
def collect():
"""Return {pack_name: [(relative_path, abs_path), ...]} grouped & sorted."""
groups = {}
for png in sorted(ART.rglob("*.png")):
if png.name == "CATALOG.png":
continue
rel = png.relative_to(ART)
pack = rel.parts[0]
groups.setdefault(pack, []).append((rel, png))
return groups
def upscale(png_path, size):
img = Image.open(png_path).convert("RGBA")
return img.resize((size, size), Image.NEAREST)
def main():
groups = collect()
if not groups:
print("No PNGs found under art/. Nothing to do.")
return
font_label = load_font(LABEL_PX)
font_section = load_font(LABEL_PX + 8)
# Calculate overall canvas size
section_height = LABEL_PX + 24
tile_block_h = TILE + PAD_Y
total_h = 40
for pack, items in groups.items():
rows = (len(items) + COLS - 1) // COLS
total_h += section_height + rows * tile_block_h + 20
total_w = COLS * (TILE + PAD_X) + PAD_X + 40
canvas = Image.new("RGBA", (total_w, total_h), BG)
draw = ImageDraw.Draw(canvas)
y = 20
for pack, items in groups.items():
# Section header
draw.rectangle([20, y, total_w - 20, y + section_height - 4], fill=SECTION_BG)
draw.text((32, y + 4), pack, font=font_section, fill=FG)
y += section_height + 6
# Tiles
for i, (rel, abs_path) in enumerate(items):
col = i % COLS
row = i // COLS
x = 20 + PAD_X // 2 + col * (TILE + PAD_X)
ty = y + row * tile_block_h
try:
tile = upscale(abs_path, TILE)
canvas.paste(tile, (x, ty), tile)
except Exception as e:
draw.rectangle([x, ty, x + TILE, ty + TILE], outline=(200, 80, 80), width=2)
draw.text((x + 4, ty + 4), f"ERR: {e}", font=font_label, fill=(255, 120, 120))
# Label: show subpath + filename below the tile
label = "/".join(rel.parts[1:]) # drop pack prefix
draw.text((x, ty + TILE + 4), label, font=font_label, fill=FG)
rows = (len(items) + COLS - 1) // COLS
y += rows * tile_block_h + 20
canvas.save(OUT)
print(f"Wrote {OUT}{sum(len(v) for v in groups.values())} textures across {len(groups)} packs.")
if __name__ == "__main__":
main()

View File

@@ -30,6 +30,13 @@ MB_BLACK = (20, 20, 20)
MB_FLAG = (235, 190, 55) MB_FLAG = (235, 190, 55)
MB_POST = (90, 60, 35) MB_POST = (90, 60, 35)
# Tent canvas — weathered green army-tent fabric
CANVAS_LIGHT = (118, 138, 88)
CANVAS_MID = (90, 110, 68)
CANVAS_DARK = (66, 84, 50)
CANVAS_SHADOW = (44, 58, 36)
CANVAS_STITCH = (190, 175, 130)
def save(img: Image.Image, rel: str) -> None: def save(img: Image.Image, rel: str) -> None:
out = ROOT / rel out = ROOT / rel
@@ -228,10 +235,174 @@ def mailbox() -> Image.Image:
return img return img
# ── Tent Canvas ────────────────────────────────────────────
# Weathered army-canvas fabric: subtle horizontal weave + occasional darker
# mottling + a vertical seam stitch line. Fully opaque so it doesn't ghost
# against the alpha_test renderer.
def tent_canvas() -> Image.Image:
img = Image.new("RGBA", (16, 16), CANVAS_MID)
# Horizontal weave bands (every 2 rows alternating tone)
for y in range(16):
row = CANVAS_MID if (y // 2) % 2 == 0 else CANVAS_DARK
rect(img, 0, y, 15, y, row)
# Diagonal weave specks for fabric texture
for y in range(16):
for x in range(16):
if (x + y) % 5 == 0:
px(img, x, y, CANVAS_LIGHT)
elif (x - y) % 7 == 0:
px(img, x, y, CANVAS_SHADOW)
# Wear / sun-bleached patches (clusters)
for (x, y) in [(2, 3), (3, 3), (10, 6), (11, 6), (5, 11), (6, 11), (13, 12)]:
px(img, x, y, CANVAS_LIGHT)
# Vertical seam stitch down the center
for y in range(0, 16, 2):
px(img, 7, y, CANVAS_STITCH)
px(img, 8, y, CANVAS_SHADOW)
# Edge darken (gives the panel some depth at block boundaries)
rect(img, 0, 0, 15, 0, CANVAS_SHADOW)
rect(img, 0, 15, 15, 15, CANVAS_SHADOW)
rect(img, 0, 0, 0, 15, CANVAS_SHADOW)
rect(img, 15, 0, 15, 15, CANVAS_SHADOW)
return img
# ── Mailbox v2 — block-style (full 16x16 face, no transparency) ─
# The previous sprite was an item-sized icon centered on transparency, which
# reads as a placeholder when applied to all 6 faces of a cube. This version
# fills the face: red body panel, vertical seam, slot, latch, riveted corners.
def mailbox_block() -> Image.Image:
img = Image.new("RGBA", (16, 16), MB_RED)
# Vertical highlight strip on the left
rect(img, 0, 0, 1, 15, MB_RED_HL)
# Vertical shadow strip on the right
rect(img, 14, 0, 15, 15, MB_RED_DARK)
# Top dome highlight (1px band)
rect(img, 2, 0, 13, 0, MB_RED_HL)
# Bottom shadow band
rect(img, 2, 15, 13, 15, MB_RED_DARK)
# Horizontal mail slot (centered)
rect(img, 4, 6, 11, 8, MB_BLACK)
rect(img, 4, 6, 11, 6, (50, 50, 50)) # slot lip highlight
rect(img, 4, 8, 11, 8, (5, 5, 5)) # slot lip shadow
# Round latch below the slot
rect(img, 7, 11, 8, 12, MB_BLACK)
px(img, 7, 11, (90, 90, 90))
# Yellow flag emblem in the top-right corner
rect(img, 12, 2, 13, 4, MB_FLAG)
px(img, 12, 2, (180, 140, 40))
# Flag pole
px(img, 13, 5, (60, 60, 60))
# Rivets in the corners
for (x, y) in [(2, 2), (13, 2), (2, 13), (13, 13)]:
px(img, x, y, (40, 15, 15))
return img
# ── Post Office v2 — block-style facade ─────────────────────
# A small post-office building face: brick wall, "POST" plaque, awning hint.
def post_office_block() -> Image.Image:
img = Image.new("RGBA", (16, 16), BRICK_RED)
# Brick courses with mortar lines (offset every other row)
for y in range(16):
# mortar line every 4 rows
if y % 4 == 0:
rect(img, 0, y, 15, y, MORTAR)
else:
# vertical mortar joints, offset by course
offset = 0 if (y // 4) % 2 == 0 else 4
for x in range(16):
if (x + offset) % 8 == 0:
px(img, x, y, MORTAR)
else:
# subtle brick tone variation
if (x + y) % 3 == 0:
px(img, x, y, BRICK_DARK)
# Cream-colored "POST" plaque (centered)
rect(img, 2, 5, 13, 10, CREAM)
rect(img, 2, 5, 13, 5, ENVELOPE_LINE) # top border
rect(img, 2, 10, 13, 10, ENVELOPE_LINE) # bottom border
rect(img, 2, 5, 2, 10, ENVELOPE_LINE) # left border
rect(img, 13, 5, 13, 10, ENVELOPE_LINE) # right border
# "POST" lettering — 4 chunky chars on the plaque
# P
rect(img, 3, 7, 4, 8, ENVELOPE_LINE); px(img, 3, 6, ENVELOPE_LINE)
# O
rect(img, 6, 6, 7, 9, ENVELOPE_LINE); px(img, 6, 7, CREAM); px(img, 7, 7, CREAM); px(img, 6, 8, CREAM); px(img, 7, 8, CREAM)
# S (simplified stub)
rect(img, 9, 6, 10, 6, ENVELOPE_LINE); px(img, 9, 7, ENVELOPE_LINE); rect(img, 9, 8, 10, 8, ENVELOPE_LINE); px(img, 10, 9, ENVELOPE_LINE); rect(img, 9, 9, 10, 9, ENVELOPE_LINE)
# T
rect(img, 11, 6, 12, 6, ENVELOPE_LINE); rect(img, 11, 7, 11, 9, ENVELOPE_LINE)
# Red stamp accent in the top-right
rect(img, 12, 1, 14, 3, STAMP_RED)
px(img, 13, 2, (255, 220, 220))
# Awning suggestion (alternating red/cream stripes at the top)
for x in range(16):
if (x // 2) % 2 == 0:
px(img, x, 1, (210, 70, 70))
else:
px(img, x, 1, CREAM)
rect(img, 0, 0, 15, 0, ENVELOPE_LINE)
return img
# ── Portal Field ───────────────────────────────────────────
# Translucent swirling-energy texture for the walk-through portal column.
# Renders with alpha blending — the dark areas read as voids, the bright
# center streaks read as concentrated portal energy.
def portal_field() -> Image.Image:
import math
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
cx, cy = 7.5, 7.5
for y in range(16):
for x in range(16):
dx = x - cx
dy = y - cy
dist = math.sqrt(dx * dx + dy * dy)
angle = math.atan2(dy, dx)
# swirl: angle modulated by distance
swirl = math.sin(angle * 3 + dist * 1.5) * 0.5 + 0.5
# purple/violet base
r = int(80 + swirl * 100)
g = int(20 + swirl * 30)
b = int(140 + swirl * 90)
# alpha falls off near edges, bright in middle bands
edge = max(0, 1 - dist / 8.5)
alpha = int(160 * edge + swirl * 60)
alpha = max(0, min(220, alpha))
img.putpixel((x, y), (r, g, b, alpha))
# Bright vertical core streaks
for y in range(16):
for x in (7, 8):
r, g, b, _ = img.getpixel((x, y))
img.putpixel((x, y), (min(255, r + 60), min(255, g + 30), min(255, b + 60), 220))
return img
def main() -> None: def main() -> None:
save(smart_crafting_table(), "smart-crafting-addon/smart_crafting_RP/textures/blocks/smart_crafting_table.png") save(smart_crafting_table(), "smart-crafting-addon/smart_crafting_RP/textures/blocks/smart_crafting_table.png")
save(post_office(), "postal-service-addon/postal_service_RP/textures/blocks/post_office.png") save(post_office_block(), "postal-service-addon/postal_service_RP/textures/blocks/post_office.png")
save(mailbox(), "postal-service-addon/postal_service_RP/textures/blocks/mailbox.png") save(mailbox_block(), "postal-service-addon/postal_service_RP/textures/blocks/mailbox.png")
save(tent_canvas(), "camping-supplies-addon/camping_supplies_RP/textures/blocks/tent_canvas.png")
save(portal_field(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_field.png")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,95 @@
{
"format_version": "1.21.0",
"minecraft:entity": {
"description": {
"identifier": "silverlabs:tow_boat",
"is_spawnable": false,
"is_summonable": true,
"is_experimental": false
},
"components": {
"minecraft:type_family": {
"family": ["tow_boat", "boat"]
},
"minecraft:health": {
"value": 20,
"max": 20
},
"minecraft:collision_box": {
"width": 1.4,
"height": 0.55
},
"minecraft:physics": {
"has_gravity": true,
"has_collision": true
},
"minecraft:pushable": {
"is_pushable": true,
"is_pushable_by_piston": false
},
"minecraft:persistent": {},
"minecraft:nameable": {},
"minecraft:movement": {
"value": 0.18
},
"minecraft:movement.basic": {},
"minecraft:navigation.float": {
"can_path_over_water": true,
"can_breach": false
},
"minecraft:underwater_movement": {
"value": 0.0
},
"minecraft:can_climb": {},
"minecraft:rideable": {
"seat_count": 1,
"family_types": ["player"],
"interact_text": "action.interact.mount",
"pull_in_entities": false,
"seats": [
{
"position": [0, 0.4, 0.0],
"lock_rider_rotation": 90,
"min_rider_count": 0,
"max_rider_count": 1
}
]
},
"minecraft:input_ground_controlled": {},
"minecraft:leashable": {
"soft_distance": 4.0,
"hard_distance": 6.0,
"max_distance": 12.0
},
"minecraft:damage_sensor": {
"triggers": [
{
"cause": "drowning",
"deals_damage": "no"
},
{
"cause": "fall",
"deals_damage": "no"
},
{
"cause": "fire",
"deals_damage": "no"
},
{
"cause": "fire_tick",
"deals_damage": "no"
},
{
"cause": "lava",
"deals_damage": "no"
},
{
"cause": "suffocation",
"deals_damage": "no"
}
]
}
},
"events": {}
}
}

View File

@@ -0,0 +1,26 @@
{
"format_version": "1.21.0",
"minecraft:item": {
"description": {
"identifier": "silverlabs:tow_boat",
"menu_category": {
"category": "equipment",
"group": "itemGroup.name.boat"
}
},
"components": {
"minecraft:max_stack_size": 1,
"minecraft:icon": "tow_boat",
"minecraft:display_name": {
"value": "Tow Boat"
},
"minecraft:entity_placer": {
"entity": "silverlabs:tow_boat",
"use_on": [
"minecraft:water",
"minecraft:flowing_water"
]
}
}
}
}

View File

@@ -0,0 +1,33 @@
{
"format_version": 2,
"header": {
"name": "Tow Boat",
"description": "A flatbed boat that can be leashed and chained for towing.",
"uuid": "a35d89fd-99e1-497a-9e78-35443c0cd59f",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "data",
"uuid": "b29d967c-8a9a-491d-867d-2866890780f0",
"version": [1, 0, 0]
},
{
"type": "script",
"uuid": "06b92593-0c15-41a0-8f39-bc6bba516ebb",
"version": [1, 0, 0],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"uuid": "c6193eda-f160-475b-ab65-fca17741e41b",
"version": [1, 0, 0]
},
{
"module_name": "@minecraft/server",
"version": "1.17.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -0,0 +1,24 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shaped": {
"description": {
"identifier": "silverlabs:tow_boat_recipe"
},
"tags": ["crafting_table"],
"unlock": [
{ "item": "minecraft:lead" }
],
"pattern": [
"PLP",
"PPP"
],
"key": {
"P": { "item": "minecraft:planks" },
"L": { "item": "minecraft:lead" }
},
"result": {
"item": "silverlabs:tow_boat",
"count": 1
}
}
}

View File

@@ -0,0 +1,150 @@
import { world, system } from "@minecraft/server";
const BOAT = "silverlabs:tow_boat";
const TOW_PROP = "silverlabs:tow_target";
const SOFT_DIST = 3.0;
const HARD_DIST = 8.0;
const PULL_GAIN = 0.20;
const TICK_RATE = 4;
function dist(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
function unitVec(from, to, len) {
return {
x: (to.x - from.x) / len,
y: (to.y - from.y) / len,
z: (to.z - from.z) / len
};
}
function getTowTarget(boat) {
const id = boat.getDynamicProperty(TOW_PROP);
return typeof id === "string" && id.length > 0 ? id : null;
}
function setTowTarget(boat, targetId) {
boat.setDynamicProperty(TOW_PROP, targetId);
}
function clearTowTarget(boat) {
boat.setDynamicProperty(TOW_PROP, "");
}
function findLeashedBoat(player) {
const overworld = world.getDimension(player.dimension.id);
const nearby = overworld.getEntities({
type: BOAT,
location: player.location,
maxDistance: 16
});
for (const boat of nearby) {
try {
const holder = boat.getComponent("leashable")?.leashHolder;
if (holder && holder.id === player.id) return boat;
} catch {
/* ignore — component may be unavailable mid-load */
}
}
return null;
}
world.beforeEvents.playerInteractWithEntity.subscribe((ev) => {
const { player, target } = ev;
if (target.typeId !== BOAT) return;
if (!player.isSneaking) return;
const inv = player.getComponent("inventory")?.container;
const held = inv?.getItem(player.selectedSlotIndex);
if (held) return; // empty hand only
if (getTowTarget(target)) {
ev.cancel = true;
system.run(() => {
clearTowTarget(target);
player.sendMessage("§eTow link removed.");
});
return;
}
const leader = findLeashedBoat(player);
if (!leader || leader.id === target.id) return;
ev.cancel = true;
system.run(() => {
setTowTarget(target, leader.id);
try {
leader.getComponent("leashable")?.unleash();
} catch {
/* unleash may not exist on all versions; safe to ignore */
}
player.sendMessage("§aTow link created — boat will follow the leader.");
});
});
function tickFollowers() {
for (const dim of ["overworld", "nether", "the_end"]) {
let boats;
try {
boats = world.getDimension(dim).getEntities({ type: BOAT });
} catch {
continue;
}
for (const boat of boats) {
const targetId = getTowTarget(boat);
if (!targetId) continue;
const target = world.getEntity(targetId);
if (!target || !target.isValid()) {
clearTowTarget(boat);
continue;
}
const d = dist(boat.location, target.location);
if (d < SOFT_DIST) continue;
if (d > HARD_DIST) {
const u = unitVec(target.location, boat.location, d);
try {
boat.teleport({
x: target.location.x + u.x * (SOFT_DIST - 0.5),
y: target.location.y,
z: target.location.z + u.z * (SOFT_DIST - 0.5)
});
} catch { /* chunk unloaded */ }
continue;
}
const u = unitVec(boat.location, target.location, d);
try {
boat.applyImpulse({
x: u.x * PULL_GAIN,
y: 0,
z: u.z * PULL_GAIN
});
} catch { /* boat may not support impulse if unloaded */ }
}
}
}
system.runInterval(tickFollowers, TICK_RATE);
world.afterEvents.entityRemove.subscribe((ev) => {
const removedId = ev.removedEntityId;
for (const dim of ["overworld", "nether", "the_end"]) {
let boats;
try {
boats = world.getDimension(dim).getEntities({ type: BOAT });
} catch {
continue;
}
for (const boat of boats) {
if (getTowTarget(boat) === removedId) clearTowTarget(boat);
}
}
});

View File

@@ -0,0 +1,27 @@
{
"format_version": "1.8.0",
"animations": {
"animation.tow_boat.bob": {
"loop": true,
"animation_length": 4.0,
"bones": {
"root": {
"position": {
"0.0": [0, 0, 0],
"1.0": [0, 0.6, 0],
"2.0": [0, 0, 0],
"3.0": [0, -0.4, 0],
"4.0": [0, 0, 0]
},
"rotation": {
"0.0": [0, 0, 0],
"1.0": [1.5, 0, -0.5],
"2.0": [0, 0, 0],
"3.0": [-1.0, 0, 0.5],
"4.0": [0, 0, 0]
}
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
{
"format_version": "1.10.0",
"minecraft:client_entity": {
"description": {
"identifier": "silverlabs:tow_boat",
"materials": {
"default": "entity_alphatest"
},
"textures": {
"default": "textures/entity/tow_boat"
},
"geometry": {
"default": "geometry.tow_boat"
},
"render_controllers": [
"controller.render.tow_boat"
],
"animations": {
"bob": "animation.tow_boat.bob"
},
"scripts": {
"animate": ["bob"]
},
"spawn_egg": {
"texture": "tow_boat",
"texture_index": 0
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"format_version": 2,
"header": {
"name": "Tow Boat Resources",
"description": "Model, texture, and animations for the Tow Boat addon.",
"uuid": "c6193eda-f160-475b-ab65-fca17741e41b",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "resources",
"uuid": "2ef90fb4-1c8f-404e-924f-a41463a51758",
"version": [1, 0, 0]
}
]
}

View File

@@ -0,0 +1,105 @@
{
"format_version": "1.12.0",
"minecraft:geometry": [
{
"description": {
"identifier": "geometry.tow_boat",
"texture_width": 64,
"texture_height": 32,
"visible_bounds_width": 3,
"visible_bounds_height": 2,
"visible_bounds_offset": [0, 0.5, 0]
},
"bones": [
{
"name": "root",
"pivot": [0, 0, 0]
},
{
"name": "hull",
"parent": "root",
"pivot": [0, 0, 0],
"cubes": [
{
"origin": [-11, 0, -16],
"size": [22, 3, 32],
"uv": [0, 0]
}
]
},
{
"name": "deck",
"parent": "hull",
"pivot": [0, 3, 0],
"cubes": [
{
"origin": [-11, 3, -16],
"size": [22, 1, 32],
"uv": [0, 16]
}
]
},
{
"name": "rail_left",
"parent": "deck",
"pivot": [-11, 4, 0],
"cubes": [
{
"origin": [-12, 4, -16],
"size": [1, 2, 32],
"uv": [0, 18]
}
]
},
{
"name": "rail_right",
"parent": "deck",
"pivot": [11, 4, 0],
"cubes": [
{
"origin": [11, 4, -16],
"size": [1, 2, 32],
"uv": [0, 18]
}
]
},
{
"name": "rail_front",
"parent": "deck",
"pivot": [0, 4, -16],
"cubes": [
{
"origin": [-11, 4, -17],
"size": [22, 2, 1],
"uv": [0, 22]
}
]
},
{
"name": "rail_back",
"parent": "deck",
"pivot": [0, 4, 16],
"cubes": [
{
"origin": [-11, 4, 16],
"size": [22, 2, 1],
"uv": [0, 22]
}
]
},
{
"name": "tow_post",
"parent": "deck",
"pivot": [0, 4, -14],
"cubes": [
{
"origin": [-1, 4, -15],
"size": [2, 4, 2],
"uv": [44, 16]
}
]
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -0,0 +1,12 @@
{
"format_version": "1.10.0",
"render_controllers": {
"controller.render.tow_boat": {
"geometry": "Geometry.default",
"materials": [
{ "*": "Material.default" }
],
"textures": ["Texture.default"]
}
}
}

View File

@@ -0,0 +1,3 @@
item.silverlabs:tow_boat=Tow Boat
entity.silverlabs:tow_boat.name=Tow Boat
action.interact.mount=Mount

View File

@@ -0,0 +1,3 @@
[
"en_US"
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

View File

@@ -0,0 +1,9 @@
{
"resource_pack_name": "tow_boat_RP",
"texture_name": "atlas.items",
"texture_data": {
"tow_boat": {
"textures": "textures/items/tow_boat"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

View File

@@ -4,11 +4,15 @@
{"pack_id": "9a3f8d2e-7c5b-4e1a-b9d2-1f6e3c4a8b50", "version": [1, 0, 0]}, {"pack_id": "9a3f8d2e-7c5b-4e1a-b9d2-1f6e3c4a8b50", "version": [1, 0, 0]},
{"pack_id": "c8e51d72-9a4f-4b3e-b8c1-2f7d3e6a4b80", "version": [1, 0, 0]}, {"pack_id": "c8e51d72-9a4f-4b3e-b8c1-2f7d3e6a4b80", "version": [1, 0, 0]},
{"pack_id": "b4d72e91-8c43-4f1a-95d2-7e3b6c8a0d10", "version": [1, 0, 0]}, {"pack_id": "b4d72e91-8c43-4f1a-95d2-7e3b6c8a0d10", "version": [1, 0, 0]},
{"pack_id": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b60", "version": [1, 0, 0]} {"pack_id": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b60", "version": [1, 0, 0]},
{"pack_id": "fac83943-16bc-4790-aa05-631894f59a03", "version": [1, 0, 0]},
{"pack_id": "a35d89fd-99e1-497a-9e78-35443c0cd59f", "version": [1, 0, 0]}
], ],
"resource_packs": [ "resource_packs": [
{"pack_id": "9a3f8d2e-7c5b-4e1a-b9d2-1f6e3c4a8b53", "version": [1, 0, 1]}, {"pack_id": "9a3f8d2e-7c5b-4e1a-b9d2-1f6e3c4a8b53", "version": [1, 0, 1]},
{"pack_id": "c8e51d72-9a4f-4b3e-b8c1-2f7d3e6a4b83", "version": [1, 0, 1]}, {"pack_id": "c8e51d72-9a4f-4b3e-b8c1-2f7d3e6a4b83", "version": [1, 0, 1]},
{"pack_id": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b63", "version": [1, 0, 0]} {"pack_id": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b63", "version": [1, 0, 0]},
{"pack_id": "a18bdde1-53f8-49aa-b06d-6f0ec6c45b46", "version": [1, 0, 0]},
{"pack_id": "c6193eda-f160-475b-ab65-fca17741e41b", "version": [1, 0, 0]}
] ]
} }