#!/usr/bin/env python3 """Generate the pixel-art textures used by the Bevy Forge demo project. All textures are drawn procedurally from a small palette so the demo stays license-clean and can be regenerated on any machine with Pillow installed: python3 examples/demo-project/tools/generate_textures.py The output is written to ``examples/demo-project/assets/textures``. """ from __future__ import annotations import random from pathlib import Path from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "assets" / "textures" OUT.mkdir(parents=True, exist_ok=True) # Shared palette (deliberately small, dusk / forest leaning). OUTLINE = (43, 32, 51, 255) SKIN = (244, 201, 158, 255) SKIN_SHADOW = (217, 164, 122, 255) HAIR = (122, 78, 45, 255) TUNIC = (54, 126, 143, 255) TUNIC_DARK = (38, 95, 110, 255) BELT = (91, 62, 38, 255) BUCKLE = (242, 179, 61, 255) PANTS = (64, 79, 105, 255) BOOT = (74, 56, 43, 255) CREAM = (255, 244, 214, 255) def logical(width: int, height: int) -> tuple[Image.Image, ImageDraw.ImageDraw]: image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) return image, ImageDraw.Draw(image) def upscale(image: Image.Image, factor: int) -> Image.Image: return image.resize( (image.width * factor, image.height * factor), Image.Resampling.NEAREST, ) def save(image: Image.Image, name: str) -> None: image.save(OUT / name, optimize=True) print(f"wrote {name}: {image.width}x{image.height}") def draw_player() -> None: image, draw = logical(16, 24) # Boots. draw.rectangle((3, 20, 7, 23), fill=OUTLINE) draw.rectangle((8, 20, 12, 23), fill=OUTLINE) draw.rectangle((4, 21, 7, 22), fill=BOOT) draw.rectangle((8, 21, 11, 22), fill=BOOT) # Legs. draw.rectangle((4, 16, 11, 20), fill=OUTLINE) draw.rectangle((5, 17, 10, 20), fill=PANTS) draw.rectangle((7, 17, 7, 19), fill=TUNIC_DARK) # Torso and arms. draw.rectangle((3, 10, 12, 16), fill=OUTLINE) draw.rectangle((4, 11, 11, 15), fill=TUNIC) draw.rectangle((2, 11, 3, 15), fill=OUTLINE) draw.rectangle((12, 11, 13, 15), fill=OUTLINE) draw.rectangle((3, 12, 3, 14), fill=TUNIC) draw.rectangle((12, 12, 12, 14), fill=TUNIC) draw.rectangle((3, 16, 4, 16), fill=SKIN) draw.rectangle((11, 16, 12, 16), fill=SKIN) draw.rectangle((3, 14, 12, 15), fill=BELT) draw.rectangle((7, 14, 8, 15), fill=BUCKLE) draw.rectangle((4, 11, 11, 11), fill=TUNIC_DARK) # Head with a friendly face. draw.rectangle((4, 2, 11, 10), fill=OUTLINE) draw.rectangle((5, 3, 10, 9), fill=SKIN) draw.rectangle((5, 2, 10, 4), fill=HAIR) draw.rectangle((4, 4, 4, 6), fill=HAIR) draw.rectangle((11, 4, 11, 6), fill=HAIR) draw.rectangle((6, 6, 6, 6), fill=OUTLINE) draw.rectangle((9, 6, 9, 6), fill=OUTLINE) draw.rectangle((7, 8, 8, 8), fill=SKIN_SHADOW) draw.rectangle((5, 8, 5, 8), fill=SKIN_SHADOW) draw.rectangle((10, 8, 10, 8), fill=SKIN_SHADOW) save(upscale(image, 4), "player.png") def draw_hat() -> None: image, draw = logical(16, 8) hat = (228, 87, 46, 255) hat_shine = (244, 138, 54, 255) brim = (188, 62, 34, 255) hat_outline = (63, 35, 28, 255) draw.rectangle((3, 1, 12, 5), fill=hat_outline) draw.rectangle((4, 2, 11, 4), fill=hat) draw.rectangle((5, 2, 8, 3), fill=hat_shine) draw.rectangle((7, 0, 8, 0), fill=hat_outline) draw.rectangle((1, 5, 14, 7), fill=hat_outline) draw.rectangle((2, 5, 13, 6), fill=brim) draw.rectangle((4, 4, 11, 4), fill=brim) save(upscale(image, 4), "hat.png") def draw_tile() -> None: image, draw = logical(24, 24) dirt = (122, 78, 45, 255) dirt_dark = (92, 58, 34, 255) dirt_light = (155, 118, 75, 255) grass = (78, 159, 61, 255) grass_light = (111, 207, 95, 255) grass_dark = (46, 91, 38, 255) draw.rectangle((0, 0, 23, 23), fill=dirt) draw.rectangle((0, 0, 23, 1), fill=grass_light) draw.rectangle((0, 2, 23, 5), fill=grass) draw.rectangle((0, 6, 23, 6), fill=grass_dark) draw.rectangle((0, 7, 23, 7), fill=dirt_dark) # Grass dribbling over the soil edge. for x in (2, 3, 8, 9, 15, 20, 21): draw.rectangle((x, 7, x, 8), fill=grass) draw.rectangle((3, 7, 6, 8), fill=grass_dark) draw.rectangle((16, 7, 18, 9), fill=grass_dark) # Soil speckles and stones. rng = random.Random(11) for _ in range(16): x = rng.randint(1, 21) y = rng.randint(10, 22) draw.rectangle((x, y, x + 1, y), fill=dirt_dark) for _ in range(8): x = rng.randint(2, 20) y = rng.randint(12, 21) draw.rectangle((x, y, x, y), fill=dirt_light) draw.rectangle((5, 14, 7, 14), fill=(155, 122, 83, 255)) draw.rectangle((17, 18, 19, 18), fill=(155, 122, 83, 255)) draw.rectangle((0, 22, 23, 23), fill=dirt_dark) # Tiny flowers so a long ground row reads as a garden. for x in (6, 19): draw.rectangle((x, 1, x, 1), fill=CREAM) draw.rectangle((x - 1, 1, x + 1, 1), fill=CREAM) draw.rectangle((x, 0, x, 0), fill=CREAM) draw.rectangle((x, 3, x, 3), fill=(78, 159, 61, 255)) draw.rectangle((x, 2, x, 2), fill=(242, 179, 61, 255)) save(upscale(image, 4), "tile.png") def draw_coin() -> None: image, draw = logical(16, 16) draw.ellipse((1, 1, 14, 14), fill=(140, 90, 22, 255)) draw.ellipse((2, 2, 13, 13), fill=(242, 179, 61, 255)) draw.ellipse((4, 4, 11, 11), fill=(255, 217, 102, 255)) draw.rectangle((7, 5, 8, 10), fill=CREAM) draw.rectangle((5, 7, 10, 8), fill=CREAM) draw.rectangle((5, 4, 6, 4), fill=(255, 255, 255, 255)) draw.rectangle((4, 5, 4, 5), fill=(255, 255, 255, 255)) save(upscale(image, 4), "coin.png") def draw_crate() -> None: image, draw = logical(16, 16) outline = (63, 43, 28, 255) wood = (169, 116, 63, 255) wood_light = (201, 143, 82, 255) wood_dark = (122, 78, 45, 255) draw.rectangle((0, 0, 15, 15), fill=outline) draw.rectangle((1, 1, 14, 14), fill=wood) draw.rectangle((1, 1, 14, 1), fill=wood_light) draw.rectangle((1, 14, 14, 14), fill=wood_dark) draw.rectangle((1, 1, 3, 14), fill=wood_light) draw.rectangle((12, 1, 14, 14), fill=wood_dark) draw.line((4, 3, 11, 12), fill=wood_dark) draw.line((11, 3, 4, 12), fill=wood_dark) for x, y in ((2, 2), (13, 2), (2, 13), (13, 13)): draw.rectangle((x, y, x, y), fill=(217, 187, 130, 255)) save(upscale(image, 4), "crate.png") def dusk_gradient(t: float) -> tuple[int, int, int]: stops = [ (0.00, (15, 14, 34)), (0.42, (45, 34, 70)), (0.62, (96, 58, 78)), (0.74, (176, 92, 70)), (0.82, (232, 146, 84)), (0.90, (244, 180, 110)), (1.00, (28, 26, 48)), ] for index in range(len(stops) - 1): start_t, start = stops[index] end_t, end = stops[index + 1] if start_t <= t <= end_t: span = max(end_t - start_t, 1e-6) blend = (t - start_t) / span return tuple( round(start[channel] + (end[channel] - start[channel]) * blend) for channel in range(3) ) return stops[-1][1] def draw_background() -> None: width, height = 240, 135 image = Image.new("RGB", (width, height)) draw = ImageDraw.Draw(image) for y in range(height): draw.line(((0, y), (width, y)), fill=dusk_gradient(y / (height - 1))) # Stars. rng = random.Random(7) for _ in range(46): x = rng.randint(2, width - 3) y = rng.randint(3, 62) brightness = rng.choice([(255, 255, 255), (255, 244, 214), (210, 205, 235)]) draw.point((x, y), fill=brightness) if rng.random() < 0.18: draw.point((x + 1, y), fill=brightness) # Soft evening clouds. draw.rectangle((0, 26, width, 28), fill=(66, 52, 92)) draw.rectangle((20, 38, 190, 39), fill=(78, 60, 104)) draw.rectangle((60, 47, 230, 48), fill=(90, 66, 105)) # Sun low over the horizon. draw.ellipse((150, 64, 191, 105), fill=(247, 190, 120)) draw.ellipse((157, 71, 184, 98), fill=(255, 226, 164)) draw.ellipse((162, 76, 179, 93), fill=(255, 244, 214)) # Far mountain range. draw.polygon( [ (0, 101), (25, 82), (48, 94), (72, 76), (95, 90), (120, 80), (150, 97), (175, 84), (205, 96), (230, 88), (240, 96), (240, 135), (0, 135), ], fill=(66, 52, 96), ) # Mid range. draw.polygon( [ (0, 112), (30, 100), (55, 108), (80, 94), (110, 106), (140, 98), (168, 110), (196, 100), (220, 108), (240, 102), (240, 135), (0, 135), ], fill=(47, 38, 72), ) # Rolling hills. draw.polygon( [ (0, 123), (35, 114), (70, 121), (110, 112), (145, 120), (180, 113), (215, 121), (240, 115), (240, 135), (0, 135), ], fill=(31, 29, 52), ) # Foreground silhouette with a few conifers. draw.polygon( [ (0, 129), (30, 126), (60, 129), (90, 125), (120, 129), (150, 126), (180, 130), (210, 126), (240, 129), (240, 135), (0, 135), ], fill=(20, 19, 34), ) for x, base in ((28, 129), (66, 130), (148, 127), (196, 131)): draw.polygon([(x, base - 12), (x - 4, base), (x + 4, base)], fill=(17, 24, 30)) draw.rectangle((x - 1, base - 13, x + 1, base - 10), fill=(17, 24, 30)) draw.rectangle((x, base - 14, x, base - 13), fill=(17, 24, 30)) for x in range(4, width, 17): draw.point((x, 132), fill=(36, 55, 42)) image = upscale(image, 4) image = image.quantize(colors=64, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) save(image.convert("RGB"), "background.png") def main() -> None: draw_background() draw_player() draw_hat() draw_tile() draw_coin() draw_crate() total = sum(path.stat().st_size for path in OUT.glob("*.png")) print(f"total texture size: {total} bytes") if __name__ == "__main__": main()