mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
- Deleted unused character images: woman_in_science_lab_coat.png and woman_with_black_long_hair_bow_in_hair_long_sleeve_(1).png. - Added new padlock icon asset for UI. - Introduced player_preferences.css for styling the player preferences configuration screen. - Updated game.js to load new character atlases with simplified filenames. - Enhanced player.js to create custom idle animations for characters. - Implemented sprite-grid.js for sprite selection UI, including a preview feature. - Updated database schema to include break_escape_player_preferences table for storing player settings. - Modified convert_pixellab_to_spritesheet.py to map character names to simplified filenames and extract headshots from character images.
389 lines
13 KiB
Python
Executable File
389 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Convert PixelLab character animations into Phaser.js sprite sheets.
|
|
|
|
This script scans a directory of character animations exported from PixelLab
|
|
and converts them into optimized sprite sheets with Phaser.js-compatible JSON atlases.
|
|
|
|
Usage:
|
|
python convert_pixellab_to_spritesheet.py <input_dir> <output_dir>
|
|
|
|
Example:
|
|
python convert_pixellab_to_spritesheet.py ~/Downloads/characters ./assets/sprites
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
import argparse
|
|
|
|
|
|
def scan_character_animations(character_dir):
|
|
"""
|
|
Scan a character directory and extract all animation frames.
|
|
|
|
Returns a dictionary structure:
|
|
{
|
|
'character_name': str,
|
|
'animations': {
|
|
'breathing-idle': {
|
|
'east': ['path/to/frame_000.png', ...],
|
|
'north': [...],
|
|
...
|
|
},
|
|
'walk': {...},
|
|
...
|
|
}
|
|
}
|
|
"""
|
|
character_dir = Path(character_dir)
|
|
animations_dir = character_dir / 'animations'
|
|
|
|
if not animations_dir.exists():
|
|
return None
|
|
|
|
character_data = {
|
|
'character_name': character_dir.name,
|
|
'animations': {}
|
|
}
|
|
|
|
# Scan animation types (breathing-idle, walk, etc.)
|
|
for anim_type_dir in sorted(animations_dir.iterdir()):
|
|
if not anim_type_dir.is_dir():
|
|
continue
|
|
|
|
anim_type = anim_type_dir.name
|
|
character_data['animations'][anim_type] = {}
|
|
|
|
# Scan directions (east, north, etc.)
|
|
for direction_dir in sorted(anim_type_dir.iterdir()):
|
|
if not direction_dir.is_dir():
|
|
continue
|
|
|
|
direction = direction_dir.name
|
|
|
|
# Collect frame files
|
|
frames = sorted([
|
|
f for f in direction_dir.iterdir()
|
|
if f.suffix.lower() in ['.png', '.jpg', '.jpeg']
|
|
])
|
|
|
|
character_data['animations'][anim_type][direction] = frames
|
|
|
|
return character_data
|
|
|
|
|
|
def get_frame_size(frames):
|
|
"""Get the size of the first frame (assumes all frames are same size)."""
|
|
if frames:
|
|
with Image.open(frames[0]) as img:
|
|
return img.size
|
|
return (0, 0)
|
|
|
|
|
|
def create_sprite_sheet(character_data, output_path, padding=2):
|
|
"""
|
|
Create a sprite sheet from all animation frames.
|
|
|
|
Returns metadata about frame positions for the atlas JSON.
|
|
"""
|
|
# Collect all frames in order
|
|
all_frames = []
|
|
frame_metadata = []
|
|
|
|
for anim_type, directions in sorted(character_data['animations'].items()):
|
|
for direction, frames in sorted(directions.items()):
|
|
for frame_path in frames:
|
|
frame_name = f"{anim_type}_{direction}_{frame_path.stem}"
|
|
all_frames.append(frame_path)
|
|
frame_metadata.append({
|
|
'path': frame_path,
|
|
'name': frame_name,
|
|
'animation': anim_type,
|
|
'direction': direction
|
|
})
|
|
|
|
if not all_frames:
|
|
raise ValueError("No frames found!")
|
|
|
|
# Get frame dimensions (assume all frames are same size)
|
|
frame_width, frame_height = get_frame_size(all_frames)
|
|
|
|
# Calculate sprite sheet dimensions
|
|
# Try to make it roughly square
|
|
num_frames = len(all_frames)
|
|
cols = int(num_frames ** 0.5) + 1
|
|
rows = (num_frames + cols - 1) // cols
|
|
|
|
sheet_width = cols * (frame_width + padding)
|
|
sheet_height = rows * (frame_height + padding)
|
|
|
|
# Create sprite sheet
|
|
sprite_sheet = Image.new('RGBA', (sheet_width, sheet_height), (0, 0, 0, 0))
|
|
|
|
# Place frames on sprite sheet
|
|
atlas_frames = {}
|
|
|
|
for idx, (frame_path, metadata) in enumerate(zip(all_frames, frame_metadata)):
|
|
col = idx % cols
|
|
row = idx // cols
|
|
|
|
x = col * (frame_width + padding)
|
|
y = row * (frame_height + padding)
|
|
|
|
# Paste frame onto sprite sheet
|
|
with Image.open(frame_path) as frame_img:
|
|
sprite_sheet.paste(frame_img, (x, y))
|
|
|
|
# Store frame position for atlas
|
|
atlas_frames[metadata['name']] = {
|
|
'frame': {
|
|
'x': x,
|
|
'y': y,
|
|
'w': frame_width,
|
|
'h': frame_height
|
|
},
|
|
'rotated': False,
|
|
'trimmed': False,
|
|
'spriteSourceSize': {
|
|
'x': 0,
|
|
'y': 0,
|
|
'w': frame_width,
|
|
'h': frame_height
|
|
},
|
|
'sourceSize': {
|
|
'w': frame_width,
|
|
'h': frame_height
|
|
},
|
|
'animation': metadata['animation'],
|
|
'direction': metadata['direction']
|
|
}
|
|
|
|
# Save sprite sheet
|
|
sprite_sheet.save(output_path)
|
|
print(f"✓ Created sprite sheet: {output_path}")
|
|
print(f" Dimensions: {sheet_width}x{sheet_height}")
|
|
print(f" Frames: {num_frames}")
|
|
print(f" Frame size: {frame_width}x{frame_height}")
|
|
|
|
return atlas_frames, frame_width, frame_height
|
|
|
|
|
|
def create_phaser_atlas(character_data, atlas_frames, sprite_sheet_filename, output_path, frame_width, frame_height):
|
|
"""
|
|
Create a Phaser.js-compatible JSON atlas file.
|
|
|
|
This uses the JSON Hash format which is widely supported by Phaser.
|
|
"""
|
|
# Group frames by animation and direction for easy reference
|
|
animations = {}
|
|
|
|
for frame_name, frame_data in atlas_frames.items():
|
|
anim_type = frame_data['animation']
|
|
direction = frame_data['direction']
|
|
|
|
anim_key = f"{anim_type}_{direction}"
|
|
|
|
if anim_key not in animations:
|
|
animations[anim_key] = []
|
|
|
|
animations[anim_key].append(frame_name)
|
|
|
|
# Create Phaser atlas structure
|
|
atlas = {
|
|
'frames': {},
|
|
'meta': {
|
|
'app': 'PixelLab to Phaser Converter',
|
|
'version': '1.0',
|
|
'image': sprite_sheet_filename,
|
|
'format': 'RGBA8888',
|
|
'size': {
|
|
'w': 0, # Will be calculated
|
|
'h': 0
|
|
},
|
|
'scale': '1'
|
|
},
|
|
'animations': animations
|
|
}
|
|
|
|
# Add frame data
|
|
for frame_name, frame_data in sorted(atlas_frames.items()):
|
|
atlas['frames'][frame_name] = {
|
|
'frame': frame_data['frame'],
|
|
'rotated': frame_data['rotated'],
|
|
'trimmed': frame_data['trimmed'],
|
|
'spriteSourceSize': frame_data['spriteSourceSize'],
|
|
'sourceSize': frame_data['sourceSize']
|
|
}
|
|
|
|
# Calculate actual sheet size from frames
|
|
if atlas_frames:
|
|
max_x = max(f['frame']['x'] + f['frame']['w'] for f in atlas_frames.values())
|
|
max_y = max(f['frame']['y'] + f['frame']['h'] for f in atlas_frames.values())
|
|
atlas['meta']['size'] = {'w': max_x, 'h': max_y}
|
|
|
|
# Save atlas JSON
|
|
with open(output_path, 'w') as f:
|
|
json.dump(atlas, f, indent=2)
|
|
|
|
print(f"✓ Created atlas JSON: {output_path}")
|
|
print(f" Animations: {len(animations)}")
|
|
|
|
# Print animation summary
|
|
print("\n Animation Summary:")
|
|
for anim_key in sorted(animations.keys()):
|
|
frame_count = len(animations[anim_key])
|
|
print(f" - {anim_key}: {frame_count} frames")
|
|
|
|
|
|
def process_character(character_dir, output_dir):
|
|
"""Process a single character directory."""
|
|
character_dir = Path(character_dir)
|
|
output_dir = Path(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"\nProcessing: {character_dir.name}")
|
|
print("=" * 60)
|
|
|
|
# Scan character animations
|
|
character_data = scan_character_animations(character_dir)
|
|
|
|
if not character_data or not character_data['animations']:
|
|
print(f"✗ No animations found in {character_dir}")
|
|
return False
|
|
|
|
# Map long directory names to short spriteSheet keys used in game.js
|
|
sprite_name_map = {
|
|
'female_woman_hacker_in_a_hoodie_hood_up_black_ob': 'female_hacker_hood',
|
|
'female_woman_office_worker_blonde_bob_hair_with_f_(2)': 'female_office_worker',
|
|
'female_woman_security_guard_uniform_tan_black_s': 'female_security_guard',
|
|
'woman_female_hacker_in_hoodie': 'female_hacker',
|
|
'woman_female_high_vis_vest_polo_shirt_telecom_w': 'female_telecom',
|
|
'woman_female_spy_in_trench_oat_duffel_coat_trilby': 'female_spy',
|
|
'woman_in_science_lab_coat': 'female_scientist',
|
|
'woman_with_black_long_hair_bow_in_hair_long_sleeve_(1)': 'woman_bow',
|
|
'hacker_in_a_hoodie_hood_up_black_obscured_face_sh': 'male_hacker_hood',
|
|
'hacker_in_hoodie_(1)': 'male_hacker',
|
|
'office_worker_white_shirt_and_tie_(7)': 'male_office_worker',
|
|
'security_guard_uniform_(3)': 'male_security_guard',
|
|
'high_vis_vest_polo_shirt_telecom_worker': 'male_telecom',
|
|
'spy_in_trench_oat_duffel_coat_trilby_hat_fedora_my': 'male_spy',
|
|
'mad_scientist_white_hair_lab_coat_lab_coat_jeans': 'male_scientist',
|
|
'red_t-shirt_jeans_sneakers_short_beard_glasses_ner_(3)': 'male_nerd',
|
|
}
|
|
char_name = character_data['character_name']
|
|
clean_name = sprite_name_map.get(char_name, char_name.lower().replace(' ', '_').replace('.', '').replace('_', '_'))
|
|
|
|
# Create output files
|
|
sprite_sheet_filename = f"{clean_name}.png"
|
|
atlas_filename = f"{clean_name}.json"
|
|
|
|
sprite_sheet_path = output_dir / sprite_sheet_filename
|
|
atlas_path = output_dir / atlas_filename
|
|
|
|
# Also update headshot filename to use clean_name
|
|
|
|
# Create sprite sheet
|
|
atlas_frames, frame_width, frame_height = create_sprite_sheet(
|
|
character_data,
|
|
sprite_sheet_path
|
|
)
|
|
|
|
# Create atlas JSON
|
|
create_phaser_atlas(
|
|
character_data,
|
|
atlas_frames,
|
|
sprite_sheet_filename,
|
|
atlas_path,
|
|
frame_width,
|
|
frame_height
|
|
)
|
|
|
|
# Extract headshot from south rotation image (center top 32x32) using 'rotation' animation
|
|
try:
|
|
south_frames = character_data['animations'].get('rotation', {}).get('south', [])
|
|
if south_frames:
|
|
with Image.open(south_frames[0]) as south_img:
|
|
# Calculate center top crop
|
|
img_width, img_height = south_img.size
|
|
headshot_size = 32
|
|
left = (img_width - headshot_size) // 2
|
|
upper = 16
|
|
right = left + headshot_size
|
|
lower = upper + headshot_size
|
|
headshot = south_img.crop((left, upper, right, lower))
|
|
headshot_filename = f"{clean_name}_headshot.png"
|
|
headshot_path = output_dir / headshot_filename
|
|
headshot.save(headshot_path)
|
|
print(f"✓ Created headshot: {headshot_path}")
|
|
else:
|
|
print("✗ No south rotation image found for headshot extraction.")
|
|
except Exception as e:
|
|
print(f"✗ Error extracting headshot: {e}")
|
|
|
|
print("\n✓ Character processing complete!")
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Convert PixelLab character animations to Phaser.js sprite sheets'
|
|
)
|
|
parser.add_argument(
|
|
'input_dir',
|
|
help='Input directory containing character folders'
|
|
)
|
|
parser.add_argument(
|
|
'output_dir',
|
|
help='Output directory for sprite sheets and atlases'
|
|
)
|
|
parser.add_argument(
|
|
'--padding',
|
|
type=int,
|
|
default=2,
|
|
help='Padding between frames in pixels (default: 2)'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
input_dir = Path(args.input_dir).expanduser()
|
|
output_dir = Path(args.output_dir).expanduser()
|
|
|
|
if not input_dir.exists():
|
|
print(f"Error: Input directory does not exist: {input_dir}")
|
|
sys.exit(1)
|
|
|
|
print("PixelLab to Phaser.js Sprite Sheet Converter")
|
|
print("=" * 60)
|
|
print(f"Input: {input_dir}")
|
|
print(f"Output: {output_dir}")
|
|
|
|
# Find all character directories
|
|
character_dirs = [d for d in input_dir.iterdir() if d.is_dir()]
|
|
|
|
if not character_dirs:
|
|
print(f"Error: No character directories found in {input_dir}")
|
|
sys.exit(1)
|
|
|
|
print(f"\nFound {len(character_dirs)} character(s) to process\n")
|
|
|
|
# Process each character
|
|
success_count = 0
|
|
for char_dir in character_dirs:
|
|
try:
|
|
if process_character(char_dir, output_dir):
|
|
success_count += 1
|
|
except Exception as e:
|
|
print(f"✗ Error processing {char_dir.name}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
print("\n" + "=" * 60)
|
|
print(f"Processing complete: {success_count}/{len(character_dirs)} successful")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|