Five freely combinable variants for floor, earth, rock and ore textures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -85,3 +85,19 @@ Keep this exact seamless tileable earth texture (same colours, same soil, same s
|
||||
Gold has four variants (`--size 128` and `--size 256` like the others). Silver, ruby and diamond
|
||||
have two variants: one seamless tile for both (`--size 64 --colors 32 --band 8` and
|
||||
`--size 128 --colors 48 --band 14`), copied to `assets/textures/<name>.png` and `<name>-128.png`.
|
||||
|
||||
## Five variants per kind (2026-11-07)
|
||||
|
||||
Floor, earth, rock and the ores (gold, silver, ruby, diamond) now have five tiles each that fit
|
||||
together in any order (`HD_SETS` in `src/gfx.rs`; a tile picks one by its position). No new
|
||||
pictures: `tools/make_tile.py --variants 5` takes the seamless sheet of a raw picture, makes one
|
||||
quarter of it seamless on its own as the shared border and fills the middles of the other tiles
|
||||
from other windows of the sheet:
|
||||
|
||||
```bash
|
||||
python3 tools/make_tile.py <name>-raw.png <name> --size 256 --colors 48 --band 28 --variants 5 # 128x128 tiles
|
||||
python3 tools/make_tile.py <name>-raw.png <name> --size 128 --colors 32 --band 14 --variants 5 # 64x64 tiles
|
||||
```
|
||||
|
||||
`<name>-v64.png` / `<name>-v128.png` are copied to `assets/textures/`; `<name>-v128-preview.png`
|
||||
shows a floor laid out at random. Water and lava keep their 2x2 blocks (they flow).
|
||||
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -6,7 +6,7 @@ use macroquad::prelude::*;
|
||||
|
||||
pub const CELL: f32 = 16.0;
|
||||
pub const ATLAS_COLS: u32 = 16;
|
||||
pub const ATLAS_ROWS: u32 = 16; // rows 14-15: cells only the high-res atlases fill (see HD_FLOORS)
|
||||
pub const ATLAS_ROWS: u32 = 18; // rows 14-17: cells only the high-res atlases fill (see HD_SETS)
|
||||
|
||||
// atlas indices (4 variants each)
|
||||
pub const T_ROCK: u32 = 0;
|
||||
@@ -337,39 +337,42 @@ fn earth_px(x: i32, y: i32, v: u32) -> Color {
|
||||
// tiles and has no seams), for kinds with two variants one seamless tile for both.
|
||||
pub const HD_TILE: u32 = 64;
|
||||
pub const HD_TILE2: u32 = 128;
|
||||
// (first cell, variants, 64x64 level, 128x128 level)
|
||||
const HD_TILES: [(u32, u32, &[u8], &[u8]); 9] = [
|
||||
(T_FLOOR, 4, include_bytes!("../assets/textures/floor.png"), include_bytes!("../assets/textures/floor-256.png")),
|
||||
(T_EARTH, 4, include_bytes!("../assets/textures/earth.png"), include_bytes!("../assets/textures/earth-256.png")),
|
||||
(T_ROCK, 4, include_bytes!("../assets/textures/rock.png"), include_bytes!("../assets/textures/rock-256.png")),
|
||||
(T_GOLD, 4, include_bytes!("../assets/textures/gold.png"), include_bytes!("../assets/textures/gold-256.png")),
|
||||
// the liquids: (first cell, variants, 64x64 level, 128x128 level); a seamless 2x2 block of
|
||||
// tiles each (variant k = quarter k % 2, k / 2), which the shader lays out by position while
|
||||
// the texture flows
|
||||
const HD_TILES: [(u32, u32, &[u8], &[u8]); 2] = [
|
||||
(T_WATER, 4, include_bytes!("../assets/textures/water.png"), include_bytes!("../assets/textures/water-256.png")),
|
||||
(T_LAVA, 4, include_bytes!("../assets/textures/lava.png"), include_bytes!("../assets/textures/lava-256.png")),
|
||||
(T_SILVER, 2, include_bytes!("../assets/textures/silver.png"), include_bytes!("../assets/textures/silver-128.png")),
|
||||
(T_RUBY, 2, include_bytes!("../assets/textures/ruby.png"), include_bytes!("../assets/textures/ruby-128.png")),
|
||||
(T_DIAMOND, 2, include_bytes!("../assets/textures/diamond.png"), include_bytes!("../assets/textures/diamond-128.png")),
|
||||
];
|
||||
|
||||
// The masters' claimed floors in the high-res style: the same grey, worn-smooth castle
|
||||
// flagstones for everybody (cut so they fit in any order), every tile with a polished inlay in
|
||||
// its master's colour and an engraving of their own (the player: a demon skull, Mossgrim: a
|
||||
// mushroom, Lady Nightwell: a lily under the moon, Saffrax: an alchemical sun). Five tiles each:
|
||||
// plain, cracked, chipped, broken, scratched. They go into cells of their own (from T_HDFLOOR on,
|
||||
// five per master); a tile picks one by its position, weighted (plain ones most often).
|
||||
// (the claimed floor's cells, the first own cell, 64x64 tiles side by side, 128x128 ones, weights)
|
||||
pub const T_HDFLOOR: u32 = 224;
|
||||
const HD_FLOORS: [(u32, u32, &[u8], &[u8], [u32; 5]); 4] = [
|
||||
(T_CLAIM, T_HDFLOOR, include_bytes!("../assets/textures/floor-player.png"), include_bytes!("../assets/textures/floor-player-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL1, T_HDFLOOR + 5, include_bytes!("../assets/textures/floor-mossgrim.png"), include_bytes!("../assets/textures/floor-mossgrim-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL2, T_HDFLOOR + 10, include_bytes!("../assets/textures/floor-nightwell.png"), include_bytes!("../assets/textures/floor-nightwell-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL3, T_HDFLOOR + 15, include_bytes!("../assets/textures/floor-saffrax.png"), include_bytes!("../assets/textures/floor-saffrax-128.png"), [4, 2, 2, 1, 3]),
|
||||
// Sets of five tiles that fit together in any order, so a floor or a wall shows no pattern:
|
||||
// the ground kinds (all five tiles share one seamless border, their middles differ; see
|
||||
// tools/make_tile.py --variants) and the masters' claimed floors (the same grey, worn-smooth
|
||||
// castle flagstones for everybody, every tile with a polished inlay in its master's colour and
|
||||
// an engraving of their own: the player a demon skull, Mossgrim a mushroom, Lady Nightwell a lily
|
||||
// under the moon, Saffrax an alchemical sun; plain, cracked, chipped, broken, scratched). They go
|
||||
// into cells of their own from T_HDSET on, five per set; a tile picks one by its position,
|
||||
// weighted. (the kind's cells: first and count, the first own cell, 64x64 tiles side by side,
|
||||
// 128x128 ones, weights)
|
||||
pub const T_HDSET: u32 = 224;
|
||||
const HD_SETS: [(u32, u32, u32, &[u8], &[u8], [u32; 5]); 11] = [
|
||||
(T_CLAIM, 4, T_HDSET, include_bytes!("../assets/textures/floor-player.png"), include_bytes!("../assets/textures/floor-player-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL1, 4, T_HDSET + 5, include_bytes!("../assets/textures/floor-mossgrim.png"), include_bytes!("../assets/textures/floor-mossgrim-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL2, 4, T_HDSET + 10, include_bytes!("../assets/textures/floor-nightwell.png"), include_bytes!("../assets/textures/floor-nightwell-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_RIVAL3, 4, T_HDSET + 15, include_bytes!("../assets/textures/floor-saffrax.png"), include_bytes!("../assets/textures/floor-saffrax-128.png"), [4, 2, 2, 1, 3]),
|
||||
(T_FLOOR, 4, T_HDSET + 20, include_bytes!("../assets/textures/floor-v64.png"), include_bytes!("../assets/textures/floor-v128.png"), [1; 5]),
|
||||
(T_EARTH, 4, T_HDSET + 25, include_bytes!("../assets/textures/earth-v64.png"), include_bytes!("../assets/textures/earth-v128.png"), [1; 5]),
|
||||
(T_ROCK, 4, T_HDSET + 30, include_bytes!("../assets/textures/rock-v64.png"), include_bytes!("../assets/textures/rock-v128.png"), [1; 5]),
|
||||
(T_GOLD, 4, T_HDSET + 35, include_bytes!("../assets/textures/gold-v64.png"), include_bytes!("../assets/textures/gold-v128.png"), [1; 5]),
|
||||
(T_SILVER, 2, T_HDSET + 40, include_bytes!("../assets/textures/silver-v64.png"), include_bytes!("../assets/textures/silver-v128.png"), [1; 5]),
|
||||
(T_RUBY, 2, T_HDSET + 45, include_bytes!("../assets/textures/ruby-v64.png"), include_bytes!("../assets/textures/ruby-v128.png"), [1; 5]),
|
||||
(T_DIAMOND, 2, T_HDSET + 50, include_bytes!("../assets/textures/diamond-v64.png"), include_bytes!("../assets/textures/diamond-v128.png"), [1; 5]),
|
||||
];
|
||||
|
||||
// the cell a tile at (x, z) shows in the high-res style: generated textures with four variants
|
||||
// are laid out by the position (see HD_TILES), the masters' floors pick one of their five slabs
|
||||
// (HD_FLOORS), everything else stays
|
||||
// the cell a tile at (x, z) shows in the high-res style: the kinds with a set pick one of its
|
||||
// five tiles (HD_SETS), the liquids are laid out by the position (HD_TILES), everything else stays
|
||||
pub fn hd_tile(id: u32, x: i32, z: i32) -> u32 {
|
||||
if let Some(&(_, own, _, _, w)) = HD_FLOORS.iter().find(|f| (f.0..f.0 + 4).contains(&id)) {
|
||||
if let Some(&(_, _, own, _, _, w)) = HD_SETS.iter().find(|f| (f.0..f.0 + f.1).contains(&id)) {
|
||||
let h = (x as u32).wrapping_mul(73_856_093) ^ (z as u32).wrapping_mul(19_349_663);
|
||||
let h = (h ^ (h >> 13)).wrapping_mul(0x5bd1_e995);
|
||||
let mut r = (h >> 8) % w.iter().sum::<u32>();
|
||||
@@ -410,8 +413,8 @@ fn gen_hd_atlas(tile: u32) -> Image {
|
||||
}
|
||||
}
|
||||
}
|
||||
for &(_, own, png, png2, _) in HD_FLOORS.iter() {
|
||||
let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).expect("hd floor");
|
||||
for &(_, _, own, png, png2, _) in HD_SETS.iter() {
|
||||
let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).expect("hd set");
|
||||
for v in 0..5 {
|
||||
let id = own + v;
|
||||
let (ox, oy) = ((id % ATLAS_COLS) * tile, (id / ATLAS_COLS) * tile);
|
||||
@@ -2768,8 +2771,8 @@ mod hd_atlas_tests {
|
||||
assert_eq!(px(&hd, ox + 7, oy + 40), px(&src, sx + 7, sy + 40));
|
||||
}
|
||||
}
|
||||
// the masters' floors: five slabs each in their own cells (inside the atlas)
|
||||
for &(_, own, png, png2, _) in HD_FLOORS.iter() {
|
||||
// the sets: five tiles each in their own cells (inside the atlas)
|
||||
for &(_, _, own, png, png2, _) in HD_SETS.iter() {
|
||||
let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).unwrap();
|
||||
assert_eq!((src.width as u32, src.height as u32), (5 * tile, tile));
|
||||
assert!(own + 5 <= ATLAS_COLS * ATLAS_ROWS);
|
||||
@@ -2785,17 +2788,18 @@ mod hd_atlas_tests {
|
||||
for z in 0..60 {
|
||||
for x in 0..60 {
|
||||
let id = hd_tile(T_CLAIM + (x as u32 % 4), x, z);
|
||||
assert!((T_HDFLOOR..T_HDFLOOR + 5).contains(&id));
|
||||
n[(id - T_HDFLOOR) as usize] += 1;
|
||||
assert!((T_HDSET..T_HDSET + 5).contains(&id));
|
||||
n[(id - T_HDSET) as usize] += 1;
|
||||
}
|
||||
}
|
||||
assert!(n.iter().all(|&k| k > 100), "{n:?}");
|
||||
assert!(n[0] > n[3] * 2, "{n:?}");
|
||||
assert!((T_HDFLOOR + 15..T_HDFLOOR + 20).contains(&hd_tile(T_RIVAL3 + 2, 3, 7)));
|
||||
// four variants by position, two variants keep theirs, others stay
|
||||
assert_eq!(hd_tile(T_FLOOR + 3, 0, 0), T_FLOOR);
|
||||
assert_eq!(hd_tile(T_FLOOR, 1, 1), T_FLOOR + 3);
|
||||
assert_eq!(hd_tile(T_RUBY + 1, 1, 0), T_RUBY + 1);
|
||||
assert!((T_HDSET + 15..T_HDSET + 20).contains(&hd_tile(T_RIVAL3 + 2, 3, 7)));
|
||||
assert!((T_HDSET + 50..T_HDSET + 55).contains(&hd_tile(T_DIAMOND + 1, 8, 2)));
|
||||
assert!((T_HDSET + 25..T_HDSET + 30).contains(&hd_tile(T_EARTH + 3, 5, 5)));
|
||||
// the liquids: four variants by position; others stay
|
||||
assert_eq!(hd_tile(T_WATER + 3, 0, 0), T_WATER);
|
||||
assert_eq!(hd_tile(T_LAVA, 1, 1), T_LAVA + 3);
|
||||
assert_eq!(hd_tile(T_TREASURY + 2, 1, 0), T_TREASURY + 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Turn a generated texture into a seamless pixel-art tile sheet for the high-res atlas.
|
||||
|
||||
Usage:
|
||||
make_tile.py raw.png out_prefix [--size 128] [--colors 32] [--band 14]
|
||||
make_tile.py raw.png out_prefix [--size 128] [--colors 32] [--band 14] [--variants N]
|
||||
|
||||
The picture is scaled down to size x size (default 128: a 2x2 block of 64x64 tiles; the game
|
||||
lays the four quarters out by the tiles' positions, so the floor repeats only every two tiles),
|
||||
@@ -10,9 +10,15 @@ made seamless (near the edges it fades into a copy shifted by half the size, who
|
||||
around without a seam; `--band` pixels wide) and reduced to a palette without dithering.
|
||||
|
||||
Writes out_prefix-<size>.png and out_prefix-<size>-preview.png (3x3 copies scaled up 3x, to
|
||||
check the seams). Needs Pillow.
|
||||
check the seams).
|
||||
|
||||
--variants N: instead N single tiles of size/2 that fit together in any order: all share the
|
||||
same seamless border (a quarter of the sheet made seamless on its own), their middles come from
|
||||
different windows of the sheet. Writes out_prefix-v<tile>.png (the tiles side by side) and
|
||||
out_prefix-v<tile>-preview.png (a floor of them laid out at random). Needs Pillow.
|
||||
"""
|
||||
import argparse
|
||||
import random
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
@@ -29,6 +35,38 @@ def seamless(img, band):
|
||||
return Image.composite(img, shifted, mask)
|
||||
|
||||
|
||||
def window(img, x, y, n):
|
||||
# an n x n window of a seamless picture, wrapping around
|
||||
return ImageChops.offset(img, -x, -y).crop((0, 0, n, n))
|
||||
|
||||
|
||||
def variants(sheet, a):
|
||||
t = a.size // 2
|
||||
base = seamless(sheet.crop((0, 0, t, t)), a.band)
|
||||
band = max(4, t * 18 // 100)
|
||||
mask = Image.new("L", (t, t))
|
||||
for y in range(t):
|
||||
for x in range(t):
|
||||
d = min(x, t - 1 - x, y, t - 1 - y)
|
||||
mask.putpixel((x, y), int(255 * max(0.0, min(1.0, (d - band * 0.4) / (band * 0.6)))))
|
||||
tiles = [base]
|
||||
for k in range(1, a.variants):
|
||||
w = window(sheet, (k * t * 37 // 100 + t // 2 * (k % 2)) % a.size, (k * t * 61 // 100) % a.size, t)
|
||||
tiles.append(Image.composite(w, base, mask))
|
||||
strip = Image.new("RGB", (t * len(tiles), t))
|
||||
for k, tile in enumerate(tiles):
|
||||
strip.paste(tile, (k * t, 0))
|
||||
strip = strip.quantize(colors=a.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB")
|
||||
strip.save(f"{a.out}-v{t}.png")
|
||||
rnd = random.Random(3)
|
||||
prev = Image.new("RGB", (t * 6, t * 4))
|
||||
for k in range(24):
|
||||
v = rnd.randrange(len(tiles))
|
||||
prev.paste(strip.crop((v * t, 0, (v + 1) * t, t)), ((k % 6) * t, (k // 6) * t))
|
||||
prev.resize((prev.width * 2, prev.height * 2), Image.NEAREST).save(f"{a.out}-v{t}-preview.png")
|
||||
print(f"{a.out}-v{t}.png: {len(tiles)} tiles")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("raw")
|
||||
@@ -36,6 +74,7 @@ def main():
|
||||
ap.add_argument("--size", type=int, default=128)
|
||||
ap.add_argument("--colors", type=int, default=32)
|
||||
ap.add_argument("--band", type=int, default=14)
|
||||
ap.add_argument("--variants", type=int, default=0)
|
||||
a = ap.parse_args()
|
||||
|
||||
img = Image.open(a.raw).convert("RGB")
|
||||
@@ -43,6 +82,8 @@ def main():
|
||||
img = img.crop(((img.width - s) // 2, (img.height - s) // 2, (img.width + s) // 2, (img.height + s) // 2))
|
||||
img = img.resize((a.size, a.size), Image.LANCZOS)
|
||||
img = seamless(img, a.band)
|
||||
if a.variants:
|
||||
return variants(img, a)
|
||||
img = img.quantize(colors=a.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB")
|
||||
img.save(f"{a.out}-{a.size}.png")
|
||||
prev = Image.new("RGB", (a.size * 3, a.size * 3))
|
||||
|
||||