Skip to content

Commit c5afffc

Browse files
authored
Add /cmap command (#472)
* cmap v2, electric boogaloo * unreadable * consistent formatting * update lang * fix/warn on possibility of item frames at same position and orientation
1 parent f6c822d commit c5afffc

File tree

4 files changed

+316
-0
lines changed

4 files changed

+316
-0
lines changed

src/main/java/net/earthcomputer/clientcommands/ClientCommands.java

+1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public static void registerCommands(CommandDispatcher<FabricClientCommandSource>
160160
KitCommand.register(dispatcher);
161161
ListenCommand.register(dispatcher);
162162
LookCommand.register(dispatcher);
163+
MapCommand.register(dispatcher);
163164
MinesweeperCommand.register(dispatcher);
164165
MoteCommand.register(dispatcher);
165166
NoteCommand.register(dispatcher);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package net.earthcomputer.clientcommands.command;
2+
3+
import com.mojang.blaze3d.platform.NativeImage;
4+
import com.mojang.brigadier.Command;
5+
import com.mojang.brigadier.CommandDispatcher;
6+
import com.mojang.brigadier.exceptions.CommandSyntaxException;
7+
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
8+
import com.mojang.logging.LogUtils;
9+
import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
10+
import net.minecraft.ChatFormatting;
11+
import net.minecraft.client.Screenshot;
12+
import net.minecraft.client.multiplayer.ClientLevel;
13+
import net.minecraft.core.BlockPos;
14+
import net.minecraft.core.Direction;
15+
import net.minecraft.core.component.DataComponents;
16+
import net.minecraft.network.chat.ClickEvent;
17+
import net.minecraft.network.chat.Component;
18+
import net.minecraft.world.entity.Entity;
19+
import net.minecraft.world.entity.LivingEntity;
20+
import net.minecraft.world.entity.decoration.ItemFrame;
21+
import net.minecraft.world.item.ItemStack;
22+
import net.minecraft.world.item.MapItem;
23+
import net.minecraft.world.level.material.MapColor;
24+
import net.minecraft.world.level.saveddata.maps.MapItemSavedData;
25+
import org.joml.Vector2i;
26+
import org.joml.Vector4i;
27+
import org.slf4j.Logger;
28+
29+
import java.io.File;
30+
import java.io.IOException;
31+
import java.util.ArrayDeque;
32+
import java.util.Arrays;
33+
import java.util.Deque;
34+
import java.util.HashMap;
35+
import java.util.HashSet;
36+
import java.util.List;
37+
import java.util.Map;
38+
import java.util.Set;
39+
import java.util.function.Function;
40+
import java.util.stream.Collectors;
41+
import java.util.stream.StreamSupport;
42+
43+
import static com.mojang.brigadier.arguments.IntegerArgumentType.*;
44+
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.*;
45+
46+
public class MapCommand {
47+
private static final Logger LOGGER = LogUtils.getLogger();
48+
49+
private static final SimpleCommandExceptionType NO_HELD_MAP_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("commands.cmap.noHeldMap"));
50+
private static final SimpleCommandExceptionType FAILED_SAVE_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("commands.cmap.failedSave"));
51+
52+
public static void register(CommandDispatcher<FabricClientCommandSource> dispatcher) {
53+
dispatcher.register(literal("cmap")
54+
.then(literal("export")
55+
.executes(ctx -> exportMap(ctx.getSource(), 1))
56+
.then(argument("scale", integer(1, 64))
57+
.executes(ctx -> exportMap(ctx.getSource(), getInteger(ctx, "scale"))))));
58+
}
59+
60+
private static int exportMap(FabricClientCommandSource source, int scale) throws CommandSyntaxException {
61+
MapItemSavedData data = fromHand(source.getPlayer());
62+
if (data != null) {
63+
return exportMap(source, new MapInfo[][] { { new MapInfo(data, 0) } }, scale, false);
64+
}
65+
66+
MapInfo[][] worldData = fromWorld(source.getClient().crosshairPickEntity, source.getClient().level, source.getPlayer().getDirection());
67+
if (worldData != null) {
68+
return exportMap(source, worldData, scale, true);
69+
}
70+
71+
throw NO_HELD_MAP_EXCEPTION.create();
72+
}
73+
74+
private static MapItemSavedData fromHand(LivingEntity entity) {
75+
ItemStack item = entity.getMainHandItem();
76+
if (item.has(DataComponents.MAP_ID)) {
77+
return MapItem.getSavedData(item, entity.level());
78+
}
79+
80+
item = entity.getOffhandItem();
81+
if (item.has(DataComponents.MAP_ID)) {
82+
return MapItem.getSavedData(item, entity.level());
83+
}
84+
85+
return null;
86+
}
87+
88+
private static MapInfo[][] fromWorld(Entity entity, ClientLevel level, Direction facing) {
89+
if (!(entity instanceof ItemFrame frame)) {
90+
return null;
91+
}
92+
93+
ItemStack item = frame.getItem();
94+
Direction direction = frame.getDirection();
95+
if (!item.has(DataComponents.MAP_ID)) {
96+
return null;
97+
}
98+
99+
// determine axis directions
100+
Direction xAxis = switch (direction) {
101+
case NORTH, SOUTH, EAST, WEST -> Direction.DOWN;
102+
case UP -> facing.getOpposite();
103+
case DOWN -> facing;
104+
};
105+
106+
Direction yAxis = switch (direction) {
107+
case NORTH -> Direction.WEST;
108+
case EAST -> Direction.NORTH;
109+
case SOUTH -> Direction.EAST;
110+
case WEST -> Direction.SOUTH;
111+
case UP -> Direction.from2DDataValue((xAxis.get2DDataValue() + 3) % 4);
112+
case DOWN -> Direction.from2DDataValue((xAxis.get2DDataValue() + 1) % 4);
113+
};
114+
115+
// collect item frames
116+
Map<BlockPos, ItemFrame> frames = StreamSupport.stream(level.entitiesForRendering().spliterator(), true)
117+
.filter(e -> e instanceof ItemFrame itemFrame && itemFrame.getDirection() == direction)
118+
.map(ItemFrame.class::cast)
119+
.collect(Collectors.toMap(ItemFrame::getPos, Function.identity(), (a, b) -> {
120+
boolean aHas = a.getItem().has(DataComponents.MAP_ID);
121+
boolean bHas = b.getItem().has(DataComponents.MAP_ID);
122+
if (aHas && bHas) {
123+
LOGGER.warn("More than one map item frame found at {}.", a.getPos());
124+
}
125+
if (aHas) {
126+
return a;
127+
}
128+
return b;
129+
}));
130+
131+
// dfs
132+
BlockPos initialPos = frame.getPos();
133+
134+
Map<Vector2i, MapInfo> positionsAndData = new HashMap<>();
135+
Set<BlockPos> visited = new HashSet<>();
136+
Deque<BlockPos> toVisit = new ArrayDeque<>();
137+
toVisit.addLast(frame.getPos());
138+
139+
while (!toVisit.isEmpty()) {
140+
BlockPos pos = toVisit.removeFirst();
141+
if (!visited.add(pos)) {
142+
continue;
143+
}
144+
145+
ItemFrame frameAtPos = frames.get(pos);
146+
if (frameAtPos == null) {
147+
continue;
148+
}
149+
150+
MapItemSavedData savedData = MapItem.getSavedData(frameAtPos.getItem(), level);
151+
152+
// compute position
153+
BlockPos relPos = pos.subtract(initialPos);
154+
Vector2i position = new Vector2i(
155+
relPos.get(xAxis.getAxis()) * xAxis.getAxisDirection().getStep(),
156+
relPos.get(yAxis.getAxis()) * yAxis.getAxisDirection().getStep());
157+
158+
// offset rotation to match with map orientation
159+
int rotationOffset = switch (xAxis) {
160+
case WEST -> 3;
161+
case EAST -> 1;
162+
case SOUTH -> direction == Direction.UP ? 0 : 2;
163+
case NORTH, UP -> direction == Direction.UP ? 2 : 0;
164+
case DOWN -> 0;
165+
};
166+
167+
if (savedData != null) {
168+
positionsAndData.put(position, new MapInfo(savedData, frameAtPos.getRotation() + rotationOffset));
169+
}
170+
171+
// add adjacent to search
172+
List<BlockPos> adjacent = switch (direction.getAxis()) {
173+
case X -> List.of(pos.north(), pos.south(), pos.above(), pos.below());
174+
case Y -> List.of(pos.north(), pos.south(), pos.east(), pos.west());
175+
case Z -> List.of(pos.east(), pos.west(), pos.above(), pos.below());
176+
};
177+
178+
toVisit.addAll(adjacent);
179+
}
180+
181+
// determine bounds
182+
// x = minX, y = minY, z = maxX, w = maxY
183+
Vector4i bounds = positionsAndData.keySet().stream().reduce(new Vector4i(0),
184+
(bound, value) -> new Vector4i(
185+
Math.min(bound.x, value.x),
186+
Math.min(bound.y, value.y),
187+
Math.max(bound.z, value.x),
188+
Math.max(bound.w, value.y)),
189+
(a, b) -> new Vector4i(
190+
Math.min(a.x, b.x),
191+
Math.min(a.y, b.y),
192+
Math.max(a.z, b.z),
193+
Math.max(a.w, b.w)));
194+
195+
int width = Math.abs(bounds.z - bounds.x) + 1;
196+
int height = Math.abs(bounds.w - bounds.y) + 1;
197+
198+
// create 2d array
199+
MapInfo[][] data = new MapInfo[width][height];
200+
for (Map.Entry<Vector2i, MapInfo> entry : positionsAndData.entrySet()) {
201+
data[entry.getKey().x - bounds.x][entry.getKey().y - bounds.y] = entry.getValue();
202+
}
203+
204+
return data;
205+
}
206+
207+
// current impl will break horribly if MapItem.IMAGE_WIDTH != MapItem.IMAGE_HEIGHT
208+
private static int exportMap(FabricClientCommandSource source, MapInfo[][] data, int scale, boolean world) throws CommandSyntaxException {
209+
int height = data.length * MapItem.IMAGE_HEIGHT;
210+
int width = Arrays.stream(data).mapToInt(a -> a.length).max().orElseThrow() * MapItem.IMAGE_WIDTH;
211+
212+
// create image
213+
NativeImage image = new NativeImage(NativeImage.Format.RGBA, width, height, true);
214+
for (int y = 0; y < data.length; y++) {
215+
for (int x = 0; x < data[y].length; x++) {
216+
MapInfo savedData = data[y][x];
217+
if (savedData == null) {
218+
continue;
219+
}
220+
221+
drawMapWithOffsets(image, x * MapItem.IMAGE_WIDTH * scale, y * MapItem.IMAGE_HEIGHT * scale, scale, savedData);
222+
}
223+
}
224+
225+
// save to screenshot dir as if it was a screenshot
226+
File screenshotDir = new File(source.getClient().gameDirectory, Screenshot.SCREENSHOT_DIR);
227+
if (!screenshotDir.exists() && !screenshotDir.mkdirs()) {
228+
throw FAILED_SAVE_EXCEPTION.create();
229+
}
230+
231+
File imageFile = Screenshot.getFile(screenshotDir);
232+
try {
233+
image.writeToFile(imageFile);
234+
} catch (IOException e) {
235+
throw FAILED_SAVE_EXCEPTION.create();
236+
}
237+
238+
source.sendFeedback(Component.translatable(world ? "commands.cmap.export.success.world" : "commands.cmap.export.success.hand", Component.literal(imageFile.getName())
239+
.withStyle(ChatFormatting.UNDERLINE)
240+
.withStyle(s -> s.withClickEvent(new ClickEvent.OpenFile(imageFile.getAbsoluteFile())))));
241+
242+
return Command.SINGLE_SUCCESS;
243+
}
244+
245+
private static void drawMapWithOffsets(NativeImage image, int offsetX, int offsetY, int scale, MapInfo info) {
246+
for (int y = 0; y < info.getHeight(); y++) {
247+
for (int x = 0; x < info.getWidth(); x++) {
248+
int color = MapColor.getColorFromPackedId(info.getMapColor(x, y));
249+
250+
for (int i = 0; i < scale; i++) {
251+
for (int j = 0; j < scale; j++) {
252+
image.setPixel((x * scale + i) + offsetX, (y * scale + j) + offsetY, color);
253+
}
254+
}
255+
}
256+
}
257+
}
258+
259+
private record MapInfo(MapItemSavedData data, int rotation) {
260+
261+
@SuppressWarnings({"UnnecessaryLocalVariable", "SuspiciousNameCombination"})
262+
public byte getMapColor(int x, int y) {
263+
switch (rotation % 4) {
264+
case 0 -> {
265+
return data.colors[x + y * MapItem.IMAGE_WIDTH];
266+
}
267+
// 90 clockwise
268+
case 1 -> {
269+
int newX = y;
270+
int newY = MapItem.IMAGE_WIDTH - 1 - x;
271+
return data.colors[newX + newY * MapItem.IMAGE_WIDTH];
272+
}
273+
// 180
274+
case 2 -> {
275+
int newX = MapItem.IMAGE_WIDTH - 1 - x;
276+
int newY = MapItem.IMAGE_WIDTH - 1 - y;
277+
return data.colors[newX + newY * MapItem.IMAGE_WIDTH];
278+
}
279+
// 270
280+
case 3 -> {
281+
int newX = MapItem.IMAGE_WIDTH - 1 - y;
282+
int newY = x;
283+
return data.colors[newX + newY * MapItem.IMAGE_WIDTH];
284+
}
285+
default -> throw new IllegalStateException("unreachable");
286+
}
287+
}
288+
289+
@SuppressWarnings("SuspiciousNameCombination")
290+
public int getWidth() {
291+
if (rotation % 2 == 0) {
292+
return MapItem.IMAGE_WIDTH;
293+
} else {
294+
return MapItem.IMAGE_HEIGHT;
295+
}
296+
}
297+
298+
@SuppressWarnings("SuspiciousNameCombination")
299+
public int getHeight() {
300+
if (rotation % 2 == 0) {
301+
return MapItem.IMAGE_HEIGHT;
302+
} else {
303+
return MapItem.IMAGE_WIDTH;
304+
}
305+
}
306+
}
307+
}

src/main/resources/assets/clientcommands/lang/en_us.json

+5
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@
203203
"commands.clisten.sentPacket": "Sent the following packet: %s",
204204
"commands.clisten.unknownPacket": "Unknown packet %s",
205205

206+
"commands.cmap.export.success.hand": "Saved held map successfully as %s",
207+
"commands.cmap.export.success.world": "Saved target map successfully as %s",
208+
"commands.cmap.failedSave": "Failed to save map to file",
209+
"commands.cmap.noHeldMap": "You are not holding or looking at a map",
210+
206211
"commands.cminesweeper.tooManyMines": "Too many mines, must be between 0 and 9 less than than the amount of total tiles",
207212

208213
"commands.cparticle.success": "Displaying particle %s",

src/main/resources/clientcommands.aw

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ accessible field net/minecraft/network/PacketDecoder protocolInfo Lnet/minecraft
3838
accessible field net/minecraft/network/PacketEncoder protocolInfo Lnet/minecraft/network/ProtocolInfo;
3939
accessible field net/minecraft/network/codec/IdDispatchCodec toId Lit/unimi/dsi/fastutil/objects/Object2IntMap;
4040

41+
# cmap
42+
accessible method net/minecraft/client/Screenshot getFile (Ljava/io/File;)Ljava/io/File;
43+
4144
# cpermissionlevel
4245
accessible method net/minecraft/client/player/LocalPlayer getPermissionLevel ()I
4346

0 commit comments

Comments
 (0)