From 649c8588eaf34dce5d5121b897e3ad98a49fb3c1 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Thu, 2 Jan 2025 14:24:22 -0500 Subject: [PATCH] Latest --- hd/README.md | 66 +++++++--- hd/hd.py | 286 ++++++++++++++++++++++++-------------------- hd/requirements.txt | 1 + 3 files changed, 209 insertions(+), 144 deletions(-) diff --git a/hd/README.md b/hd/README.md index 601a1a1..8aefaea 100644 --- a/hd/README.md +++ b/hd/README.md @@ -12,18 +12,39 @@ Transform your favorite retro worm into glorious high definition! This tool lets ## 🎯 Quick Start -### Extract the original sprites: -```bash -python hd.py extract willy.chr sprites_folder/ -``` +### Command Line Interface -### Create a new HD character file: ```bash -python hd.py create sprites_folder/ willy_hd.chr +python hd.py [options] ``` -### View available character types: +Available commands: +- `extract`: Extract sprites from a chr file +- `create`: Create a new chr file from sprites +- `info`: Display available character types + +Options: +- `--input`: Input chr file (for extract) or directory (for create) +- `--output`: Output directory (for extract) or chr file (for create) +- `--size`: Output size for extraction (8 or 128 pixels) +- `--classic`: Create classic 8x8 format chr file + +Examples: + ```bash +# Extract to 8x8 sprites +python hd.py extract --input willy.chr --output sprites/ --size 8 + +# Extract to 128x128 HD sprites +python hd.py extract --input willy.chr --output sprites/ --size 128 + +# Create classic 8x8 chr file +python hd.py create --input sprites/ --output willy.chr --classic + +# Create HD 128x128 chr file +python hd.py create --input sprites/ --output willy.chr + +# View available character types python hd.py info ``` @@ -46,16 +67,27 @@ Each sprite can be edited in your favorite image editor as a 128x128 PNG file wi ## 🛠️ Technical Details -The tool supports two formats: -- Classic 8x8 bitmap format from the original game -- New HD format with 128x128 RGBA sprites and JSON metadata - -The HD format includes: -- Version identifier -- Image dimensions -- RGBA channel support -- Character mapping data -- Raw pixel data for each sprite +The tool fully supports both sprite formats: +- Classic Format: + - 8x8 bitmap sprites + - 1-bit per pixel (black and white) + - Original game compatibility + - Compact file size + +- HD Format: + - 128x128 RGBA sprites + - Full color and alpha channel support + - JSON metadata including: + - Version identifier + - Image dimensions + - Character mapping data + - Automatic format detection + +You can freely convert between formats: +- Upscale classic 8x8 sprites to 128x128 HD versions +- Downscale HD sprites to classic 8x8 format +- Extract and modify sprites while preserving original format +- Mix and match sprite sizes in your workflow ## 🌟 Pro Tips diff --git a/hd/hd.py b/hd/hd.py index a210f32..3339b38 100644 --- a/hd/hd.py +++ b/hd/hd.py @@ -3,12 +3,18 @@ import numpy as np import os import json +import argparse -def extract_characters(chr_file, output_dir): +def extract_characters(chr_file, output_dir, size=128): """ - Extract character bitmaps from willy.chr into individual 128x128 PNG files with transparency + Extract character bitmaps from willy.chr into individual PNG files + Supports both old 8x8 and new 128x128 formats + + Args: + chr_file: Path to the chr file + output_dir: Directory to save extracted PNGs + size: Output size (8 or 128) """ - # Character mapping from the original code namedpart = { "0": "WILLY_RIGHT", "1": "WILLY_LEFT", @@ -20,52 +26,61 @@ def extract_characters(chr_file, output_dir): "7": "BALL", "8": "BELL" } - # Add pipe parts 51-90 for i in range(51, 91): namedpart[str(i)] = f"PIPE{i-50}" namedpart["126"] = "BALLPIT" namedpart["127"] = "EMPTY" - # Create output directory if it doesn't exist if not os.path.exists(output_dir): os.makedirs(output_dir) - # Read the character data file - with open(chr_file, 'rb') as f: - data = bytearray(f.read()) - - # Process each character from original 8x8 format - for i in range(len(data) // 8): - # Convert 8 bytes into 8x8 bitmap with alpha - bitmap = np.zeros((8, 8, 4), dtype=np.uint8) - for y in range(8): - byte = data[i * 8 + y] - for x in range(8): - pixel = (byte >> (7 - x)) & 1 - if pixel: - # White pixel with full opacity - bitmap[y, x] = [255, 255, 255, 255] - else: - # Black pixel with full transparency - bitmap[y, x] = [0, 0, 0, 0] - - # Create PIL image with alpha channel - img = Image.fromarray(bitmap, 'RGBA') - - # Scale up to 128x128 using nearest neighbor to preserve sharp edges - img = img.resize((128, 128), Image.NEAREST) - - # Save the image if it has a name - char_name = namedpart.get(str(i)) - if char_name: - img.save(os.path.join(output_dir, f"{char_name}.png"), 'PNG') + metadata, data = read_chr_file(chr_file) + + if metadata: # New format (128x128) + char_size = metadata["width"] * metadata["height"] * metadata["channels"] + for i in range(len(data) // char_size): + char_name = namedpart.get(str(i)) + if char_name: + start = i * char_size + end = start + char_size + pixel_data = data[start:end] + img = Image.frombytes('RGBA', (128, 128), pixel_data) + + if size == 8: # Downscale to 8x8 + img = img.resize((8, 8), Image.Resampling.LANCZOS) + + img.save(os.path.join(output_dir, f"{char_name}.png"), 'PNG') + else: # Old format (8x8) + data = bytearray(data) + for i in range(len(data) // 8): + char_name = namedpart.get(str(i)) + if char_name: + bitmap = np.zeros((8, 8, 4), dtype=np.uint8) + for y in range(8): + byte = data[i * 8 + y] + for x in range(8): + pixel = (byte >> (7 - x)) & 1 + if pixel: + bitmap[y, x] = [255, 255, 255, 255] + else: + bitmap[y, x] = [0, 0, 0, 0] + + img = Image.fromarray(bitmap, 'RGBA') + if size == 128: # Upscale to 128x128 + img = img.resize((128, 128), Image.NEAREST) + + img.save(os.path.join(output_dir, f"{char_name}.png"), 'PNG') -def create_chr_file(input_dir, output_file): +def create_chr_file(input_dir, output_file, classic_mode=False): """ - Create a new willy.chr file from 128x128 RGBA PNG images. - New format: JSON header with character mapping + concatenated raw RGBA data + Create a new willy.chr file from PNG images. + Supports both 8x8 (classic) and 128x128 (HD) formats. + + Args: + input_dir: Directory containing PNG files + output_file: Output chr file path + classic_mode: If True, create old format 8x8 chr file """ - # Character mapping (reversed) namedpart_reverse = { "WILLY_RIGHT": 0, "WILLY_LEFT": 1, @@ -77,78 +92,74 @@ def create_chr_file(input_dir, output_file): "BALL": 7, "BELL": 8 } - # Add pipe parts for i in range(1, 41): namedpart_reverse[f"PIPE{i}"] = 50 + i namedpart_reverse["BALLPIT"] = 126 namedpart_reverse["EMPTY"] = 127 - # Format metadata - metadata = { - "version": 2, - "width": 128, - "height": 128, - "channels": 4, # RGBA - "characters": namedpart_reverse - } - - # Initialize output data - header_bytes = json.dumps(metadata).encode('utf-8') - header_size = len(header_bytes) - header_size_bytes = header_size.to_bytes(4, byteorder='little') - - # Open output file - with open(output_file, 'wb') as f: - # Write header size and header - f.write(header_size_bytes) - f.write(header_bytes) - - # Process each character - max_char_index = max(namedpart_reverse.values()) + 1 - for char_index in range(max_char_index): - # Find character name if it exists - char_name = None - for name, idx in namedpart_reverse.items(): - if idx == char_index: - char_name = name - break - - if char_name and os.path.exists(os.path.join(input_dir, f"{char_name}.png")): - # Load image - img = Image.open(os.path.join(input_dir, f"{char_name}.png")) - img = img.convert('RGBA') - if img.size != (128, 128): - img = img.resize((128, 128), Image.NEAREST) + if classic_mode: + # Create old format 8x8 chr file + with open(output_file, 'wb') as f: + max_char_index = max(namedpart_reverse.values()) + 1 + for char_index in range(max_char_index): + char_name = None + for name, idx in namedpart_reverse.items(): + if idx == char_index: + char_name = name + break - # Write raw pixel data - f.write(img.tobytes()) - else: - # Write empty character (transparent) - empty_data = bytes([0] * (128 * 128 * 4)) - f.write(empty_data) - -def print_character_info(): - """Print available character names and their indices""" - namedpart = { - 0: "WILLY_RIGHT", - 1: "WILLY_LEFT", - 2: "PRESENT", - 3: "LADDER", - 4: "TACK", - 5: "UPSPRING", - 6: "SIDESPRING", - 7: "BALL", - 8: "BELL" - } - # Add pipe parts 51-90 - for i in range(51, 91): - namedpart[i] = f"PIPE{i-50}" - namedpart[126] = "BALLPIT" - namedpart[127] = "EMPTY" - - print("\nAvailable characters:") - for idx, name in sorted(namedpart.items()): - print(f"{idx}: {name}") + if char_name and os.path.exists(os.path.join(input_dir, f"{char_name}.png")): + img = Image.open(os.path.join(input_dir, f"{char_name}.png")) + img = img.convert('RGBA') + if img.size != (8, 8): + img = img.resize((8, 8), Image.Resampling.LANCZOS) + + # Convert to 8x8 bitmap + bitmap = np.array(img) + char_data = bytearray(8) + for y in range(8): + byte = 0 + for x in range(8): + if bitmap[y, x][3] > 127: # Check alpha channel + byte |= (1 << (7 - x)) + char_data[y] = byte + f.write(char_data) + else: + f.write(bytes([0] * 8)) + else: + # Create new format 128x128 chr file + metadata = { + "version": 2, + "width": 128, + "height": 128, + "channels": 4, + "characters": namedpart_reverse + } + + header_bytes = json.dumps(metadata).encode('utf-8') + header_size = len(header_bytes) + header_size_bytes = header_size.to_bytes(4, byteorder='little') + + with open(output_file, 'wb') as f: + f.write(header_size_bytes) + f.write(header_bytes) + + max_char_index = max(namedpart_reverse.values()) + 1 + for char_index in range(max_char_index): + char_name = None + for name, idx in namedpart_reverse.items(): + if idx == char_index: + char_name = name + break + + if char_name and os.path.exists(os.path.join(input_dir, f"{char_name}.png")): + img = Image.open(os.path.join(input_dir, f"{char_name}.png")) + img = img.convert('RGBA') + if img.size != (128, 128): + img = img.resize((128, 128), Image.NEAREST) + f.write(img.tobytes()) + else: + f.write(bytes([0] * (128 * 128 * 4))) def read_chr_file(chr_file): """ @@ -156,39 +167,60 @@ def read_chr_file(chr_file): Supports both old (8x8) and new (128x128) formats. """ with open(chr_file, 'rb') as f: - # Try to read as new format first try: header_size = int.from_bytes(f.read(4), byteorder='little') header_data = f.read(header_size) metadata = json.loads(header_data.decode('utf-8')) - # It's a new format file if metadata["version"] == 2: return metadata, f.read() except: - # If that fails, assume it's an old format file f.seek(0) return None, f.read() -if __name__ == '__main__': - import sys +def main(): + parser = argparse.ArgumentParser(description='Willy the Worm HD Sprite Tool') + parser.add_argument('command', choices=['extract', 'create', 'info'], + help='Command to execute') + parser.add_argument('--input', help='Input chr file or directory') + parser.add_argument('--output', help='Output directory or chr file') + parser.add_argument('--size', type=int, choices=[8, 128], default=128, + help='Output size for extraction (8 or 128 pixels)') + parser.add_argument('--classic', action='store_true', + help='Create classic 8x8 format chr file') + + args = parser.parse_args() - if len(sys.argv) < 2 or sys.argv[1] not in ['extract', 'create', 'info']: - print("Usage:") - print(" Extract: python willy_chr_tools.py extract ") - print(" Create: python willy_chr_tools.py create ") - print(" Info: python willy_chr_tools.py info") - sys.exit(1) + if args.command == 'extract': + if not args.input or not args.output: + parser.error('extract command requires --input and --output arguments') + extract_characters(args.input, args.output, args.size) + + elif args.command == 'create': + if not args.input or not args.output: + parser.error('create command requires --input and --output arguments') + create_chr_file(args.input, args.output, args.classic) + + else: # info + print("\nAvailable characters:") + namedpart = { + 0: "WILLY_RIGHT", + 1: "WILLY_LEFT", + 2: "PRESENT", + 3: "LADDER", + 4: "TACK", + 5: "UPSPRING", + 6: "SIDESPRING", + 7: "BALL", + 8: "BELL" + } + for i in range(51, 91): + namedpart[i] = f"PIPE{i-50}" + namedpart[126] = "BALLPIT" + namedpart[127] = "EMPTY" + + for idx, name in sorted(namedpart.items()): + print(f"{idx}: {name}") - if sys.argv[1] == 'extract': - if len(sys.argv) != 4: - print("Extract usage: python willy_chr_tools.py extract ") - sys.exit(1) - extract_characters(sys.argv[2], sys.argv[3]) - elif sys.argv[1] == 'create': - if len(sys.argv) != 4: - print("Create usage: python willy_chr_tools.py create ") - sys.exit(1) - create_chr_file(sys.argv[2], sys.argv[3]) - else: - print_character_info() +if __name__ == '__main__': + main() diff --git a/hd/requirements.txt b/hd/requirements.txt index 0d33a9e..bd14add 100644 --- a/hd/requirements.txt +++ b/hd/requirements.txt @@ -1,2 +1,3 @@ numpy pillow +argparse