Map: block colours from real block textures; palette-limited PNG tiles

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-10-01 00:34:21 +00:00
1 parent 30b1d92437
commit ead4a706c7
7 files changed
+561 -34

No files matched your search

@@ -35,13 +35,71 @@ public final class BlockColors {
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
/** 0 for blocks a map looks straight through (air, glass, rails, torches). */
/** Water is special-cased by the renderer, which shades it by depth. */
public static final int WATER_COLOR = 0xFF3F76E4;
private static final Map<String, Integer> TEXTURES = loadTextures();
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
/**
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
*/
public static int of(String blockName) {
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
}
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
public static int mapColor(String blockName) {
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
}
private static int resolve(String full) {
int id = mapColor(full);
if (id == 0) return 0;
if (id == WATER) return WATER_COLOR;
String n = plain(full);
Integer exact = TEXTURES.get(n);
if (exact != null) return exact;
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (!changed) break;
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
Integer hit = TEXTURES.get(candidate);
if (hit != null) return hit;
}
}
return MapColors.base(id);
}
private static String plain(String full) {
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
}
private static Map<String, Integer> loadTextures() {
Map<String, Integer> out = new java.util.HashMap<>();
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
if (in == null) return out;
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
int comma = line.indexOf(',');
if (comma <= 0 || comma + 7 > line.length()) continue;
try {
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
} catch (NumberFormatException ignored) {
// a malformed row just falls back to the map colour table
}
}
} catch (java.io.IOException ignored) {
// no texture table: the map colour table covers everything
}
return out;
}
private static int classify(String full) {
String n = full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
String n = plain(full);
int c = lookup(n);
if (c >= 0) return c;
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
@@ -150,5 +208,5 @@ public final class BlockColors {
return -1;
}
public static boolean isWater(int colour) { return colour == WATER; }
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
}
@@ -82,7 +82,7 @@ public final class ChunkSampler {
if (c == 0) continue;
out.color[i] = c;
out.height[i] = worldY;
if (c == MapColors.WATER) out.depth[i] = waterDepth(sections, si, ly, x, z);
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
return;
}
}
@@ -97,7 +97,7 @@ public final class ChunkSampler {
for (int s = si; s < sections.size(); s++) {
Section sec = sections.get(s);
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
if (sec.colorAt(x, y, z) != MapColors.WATER) return depth;
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
if (++depth >= 64) return depth;
}
}
@@ -25,6 +25,9 @@ public final class MapColors {
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
private static final int[] SHADE = {180, 220, 255, 135};
/** The colour's base RGB (opaque ARGB), or 0 for none. */
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
public static int argb(int id, int shade) {
if (id <= 0 || id >= COUNT) return 0;
@@ -3,6 +3,9 @@ package net.scopenet.worldmap;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -18,6 +21,7 @@ public final class Png {
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
*/
public static byte[] encode(int[] argb, int width, int height) {
argb = limitColors(argb);
Map<Integer, Integer> palette = new LinkedHashMap<>();
for (int c : argb) {
int key = (c >>> 24) == 0 ? 0 : c;
@@ -65,6 +69,36 @@ public final class Png {
return out.toByteArray();
}
/**
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
*/
static int[] limitColors(int[] argb) {
Map<Integer, Integer> counts = new HashMap<>();
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
if (counts.size() <= 255) return argb;
List<Integer> keep = new ArrayList<>(counts.keySet());
keep.sort((a, b) -> counts.get(b) - counts.get(a));
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
Set<Integer> kept = new HashSet<>(top);
Map<Integer, Integer> remap = new HashMap<>();
int[] out = argb.clone();
for (int i = 0; i < out.length; i++) {
int c = out[i];
if ((c >>> 24) == 0 || kept.contains(c)) continue;
out[i] = remap.computeIfAbsent(c, k -> {
int best = top.get(0), bestD = Integer.MAX_VALUE;
for (int q : top) {
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
if (d < bestD) { bestD = d; best = q; }
}
return best;
});
}
return out;
}
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
private static byte[] deflate(byte[] raw) {
@@ -47,7 +47,10 @@ public final class RegionRenderer {
return tiles;
}
/** Shading as a vanilla map does it: slope against the block to the north, depth for water, plus a light dither. */
/**
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
* Colours are the blocks' own, so they stay in few steps and compress well.
*/
static int[] shade(int[] colors, int[] heights, int[] depths) {
int[] out = new int[SIDE * SIDE];
for (int z = 0; z < SIDE; z++) {
@@ -55,18 +58,23 @@ public final class RegionRenderer {
int i = z * SIDE + x;
int c = colors[i];
if (c == 0) continue;
int shade;
if (c == MapColors.WATER) {
double d = depths[i] * 0.1 + ((x + z) & 1) * 0.2;
shade = d < 0.5 ? 2 : d > 0.9 ? 0 : 1;
int percent;
if (c == BlockColors.WATER_COLOR) {
percent = 108 - Math.min(depths[i], 14) * 4;
} else {
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
double d = (heights[i] - north) * 4.0 / 5.0 + (((x + z) & 1) - 0.5) * 0.4;
shade = d > 0.6 ? 2 : d < -0.6 ? 0 : 1;
int diff = heights[i] - north;
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
}
out[i] = MapColors.argb(c, shade);
out[i] = scale(c, percent);
}
}
return out;
}
static int scale(int argb, int percent) {
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
b = Math.min(255, (argb & 0xFF) * percent / 100);
return 0xFF000000 | r << 16 | g << 8 | b;
}
}