diff --git a/assets/textures/ai/game_ready/asphalt.png b/assets/textures/ai/game_ready/asphalt.png new file mode 100644 index 0000000..ae86da4 Binary files /dev/null and b/assets/textures/ai/game_ready/asphalt.png differ diff --git a/assets/textures/ai/game_ready/awning.png b/assets/textures/ai/game_ready/awning.png new file mode 100644 index 0000000..24a8572 Binary files /dev/null and b/assets/textures/ai/game_ready/awning.png differ diff --git a/assets/textures/ai/game_ready/brick_facade.png b/assets/textures/ai/game_ready/brick_facade.png new file mode 100644 index 0000000..2280870 Binary files /dev/null and b/assets/textures/ai/game_ready/brick_facade.png differ diff --git a/assets/textures/ai/game_ready/cards/credential.png b/assets/textures/ai/game_ready/cards/credential.png new file mode 100644 index 0000000..fead2fe Binary files /dev/null and b/assets/textures/ai/game_ready/cards/credential.png differ diff --git a/assets/textures/ai/game_ready/cards/guest_list.png b/assets/textures/ai/game_ready/cards/guest_list.png new file mode 100644 index 0000000..d5bd7b5 Binary files /dev/null and b/assets/textures/ai/game_ready/cards/guest_list.png differ diff --git a/assets/textures/ai/game_ready/cards/id_card.png b/assets/textures/ai/game_ready/cards/id_card.png new file mode 100644 index 0000000..0833b67 Binary files /dev/null and b/assets/textures/ai/game_ready/cards/id_card.png differ diff --git a/assets/textures/ai/game_ready/cards/ticket.png b/assets/textures/ai/game_ready/cards/ticket.png new file mode 100644 index 0000000..dc4d854 Binary files /dev/null and b/assets/textures/ai/game_ready/cards/ticket.png differ diff --git a/assets/textures/ai/game_ready/cards/wristband.png b/assets/textures/ai/game_ready/cards/wristband.png new file mode 100644 index 0000000..0aabdc8 Binary files /dev/null and b/assets/textures/ai/game_ready/cards/wristband.png differ diff --git a/assets/textures/ai/game_ready/concrete.png b/assets/textures/ai/game_ready/concrete.png new file mode 100644 index 0000000..8f673d9 Binary files /dev/null and b/assets/textures/ai/game_ready/concrete.png differ diff --git a/assets/textures/ai/game_ready/icons/door_locked.png b/assets/textures/ai/game_ready/icons/door_locked.png new file mode 100644 index 0000000..8122707 Binary files /dev/null and b/assets/textures/ai/game_ready/icons/door_locked.png differ diff --git a/assets/textures/ai/game_ready/icons/flashlight_on.png b/assets/textures/ai/game_ready/icons/flashlight_on.png new file mode 100644 index 0000000..5e2705c Binary files /dev/null and b/assets/textures/ai/game_ready/icons/flashlight_on.png differ diff --git a/assets/textures/ai/game_ready/icons/rec_dot.png b/assets/textures/ai/game_ready/icons/rec_dot.png new file mode 100644 index 0000000..ae6a673 Binary files /dev/null and b/assets/textures/ai/game_ready/icons/rec_dot.png differ diff --git a/assets/textures/ai/game_ready/icons/wand.png b/assets/textures/ai/game_ready/icons/wand.png new file mode 100644 index 0000000..9fd042b Binary files /dev/null and b/assets/textures/ai/game_ready/icons/wand.png differ diff --git a/assets/textures/ai/game_ready/skyline.png b/assets/textures/ai/game_ready/skyline.png new file mode 100644 index 0000000..1db4a0c Binary files /dev/null and b/assets/textures/ai/game_ready/skyline.png differ diff --git a/assets/textures/ai/game_ready/vignette.png b/assets/textures/ai/game_ready/vignette.png new file mode 100644 index 0000000..080c4e8 Binary files /dev/null and b/assets/textures/ai/game_ready/vignette.png differ diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 5332a63..9c07e66 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -5,16 +5,16 @@ extends CanvasLayer const Rules := preload("res://src/core/rules.gd") const Strings := preload("res://src/core/strings.gd") -const T_VIGNETTE := preload("res://assets/textures/vignette.png") -const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png") -const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png") -const T_ICON_WAND := preload("res://assets/textures/icons/wand.png") -const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png") -const T_CARD_ID := preload("res://assets/textures/cards/id_card.png") -const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png") -const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png") -const T_CARD_CRED := preload("res://assets/textures/cards/credential.png") -const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png") +const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png") +const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png") +const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png") +const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png") +const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png") +const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png") +const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png") +const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png") +const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png") +const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png") const CARD_TEXTURES := { Rules.AccessKind.ID: T_CARD_ID, Rules.AccessKind.WRISTBAND: T_CARD_WRIST, diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index a204271..6c05c8d 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -1,11 +1,11 @@ class_name WorldBuilder ## Procedurally assembles a minimal Hollywood Boulevard entrance prototype: -const T_BRICK := preload("res://assets/textures/brick_facade.png") -const T_CONCRETE := preload("res://assets/textures/concrete.png") -const T_ASPHALT := preload("res://assets/textures/asphalt.png") -const T_AWNING := preload("res://assets/textures/awning.png") -const T_SKYLINE := preload("res://assets/textures/skyline.png") +const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png") +const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png") +const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png") +const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png") +const T_SKYLINE := preload("res://assets/textures/ai/game_ready/skyline.png") ## steps, building facade, restricted door, sidewalk, pavement, and street edge. ## No art assets required; all geometry is BoxMesh with flat materials. diff --git a/tools/asset_pipeline.py b/tools/asset_pipeline.py index 4194b31..baa8bb7 100644 --- a/tools/asset_pipeline.py +++ b/tools/asset_pipeline.py @@ -12,6 +12,12 @@ import generate_textures ROOT = Path(__file__).resolve().parent.parent IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"} +DEFAULT_ROLE_IMAGE_MODEL = "openai/gpt-5-image-mini" +API_ASPECTS = { + "texture": "1:1", "tile": "1:1", "icon": "1:1", "app_icon": "1:1", + "overlay": "1:1", "card": "3:2", "banner": "3:2", "backdrop": "3:2", + "background": "3:2", +} PROFILES = { "texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024), @@ -24,15 +30,20 @@ PROFILES = { "app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024), "aspect": 1.0, "alpha": None, "view": "square, centered composition"}, "overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024), - "aspect": 1.0, "alpha": None, "view": "screen-space overlay"}, + "aspect": 1.0, "alpha": None, "view": "screen-space overlay", + "min_contrast": 5.0}, "card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512), "aspect": 2.0, "alpha": None, "view": "flat, front-facing card"}, "banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512), "aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"}, "backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048), - "aspect": 4.0, "alpha": None, "view": "straight-on wide backdrop"}, + "aspect": 4.0, "alpha": True, + "view": "straight-on wide backdrop on transparency"}, "background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320), "aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"}, + "screenshot": {"target": (1280, 720), "min": (1280, 720), "max": (3840, 2160), + "aspect": 16 / 9, "alpha": None, "view": "gameplay frame, venue, and UI state", + "min_contrast": 5.0}, } ROLE_GUIDANCE = { @@ -41,10 +52,10 @@ ROLE_GUIDANCE = { "icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective", "app_icon": "square game application icon, centered composition, no perspective", "overlay": "screen-space overlay texture with transparent edges, no perspective", - "card": "single front-facing flat game UI card, no perspective, no surrounding mockup", - "banner": "wide front-facing game banner texture, no perspective, no surrounding scene", - "backdrop": "wide seamless-looking game environment backdrop, straight-on view, no foreground objects", - "background": "wide 16:9 game background environment with a coherent horizon and straight camera view", + "card": "single wide landscape game UI card filling the frame, front-facing and flat, no perspective, no surrounding mockup or whitespace", + "banner": "wide front-facing game banner texture, keep important details in a narrow horizontal band through the image center for final cropping, no perspective, no surrounding scene", + "backdrop": "wide isolated skyline silhouette across a low horizon in a narrow horizontal band through the image center, transparent background, no foreground objects", + "background": "wide 16:9 game background with a coherent horizon and straight camera view, keep important details in the central horizontal band for final cropping", } @@ -137,6 +148,8 @@ def resize_image(source, output, width, height, fit="contain", background=(0, 0, def infer_role(path): parts = [part.lower() for part in path.parts] stem = path.stem.lower() + if "screenshots" in parts or "screenshot" in stem: + return "screenshot" if "icons" in parts: return "icon" if "cards" in parts: @@ -193,6 +206,8 @@ def audit_image(path, role="auto", seam_threshold=18.0): f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}") if profile.get("alpha") is True and not alpha: errors.append("transparency required but image is opaque") + if alpha and image.convert("RGBA").getchannel("A").getbbox() is None: + errors.append("image is fully transparent") elif profile.get("alpha") is False and alpha: warnings.append( "image has an alpha channel although this role is normally opaque") @@ -204,20 +219,27 @@ def audit_image(path, role="auto", seam_threshold=18.0): if edge_difference > seam_threshold: errors.append( f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})") + contrast = None + if profile.get("min_contrast") is not None: + contrast = ImageStat.Stat(image.convert("L")).stddev[0] + if contrast < profile["min_contrast"]: + errors.append( + f"near-blank frame: contrast {contrast:.1f} is below {profile['min_contrast']:.1f}") manual_review = [ f"Confirm {profile.get('view', 'intended')} perspective/projection visually; " "automated checks cannot infer it reliably." ] return { "path": str(path), "role": chosen_role, "size": [width, height], - "alpha": alpha, "edge_difference": edge_difference, + "alpha": alpha, "edge_difference": edge_difference, "contrast": contrast, "quality": "bad" if errors else "good", "errors": errors, "warnings": warnings, "manual_review": manual_review, } except (OSError, ValueError) as exc: return { "path": str(path), "role": chosen_role, "size": None, "alpha": None, - "edge_difference": None, "quality": "bad", "errors": [f"cannot read image: {exc}"], + "edge_difference": None, "contrast": None, "quality": "bad", + "errors": [f"cannot read image: {exc}"], "warnings": [], "manual_review": [], } @@ -246,13 +268,19 @@ def _generate(args): "alpha") is True prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}." image = generate_textures.generate_image( - prompt, args.model, args.request_size, transparent) + prompt, args.model, args.request_size, transparent, + aspect_ratio=API_ASPECTS[args.role]) + foreground = getattr(args, "foreground", None) + if foreground: + image = colorize_alpha(image, foreground) if args.role == "icon" and has_alpha(image): image = resize_image_from_image( image, width, height, "contain", (0, 0, 0, 0)) else: image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS) + if args.role == "tile": + image = make_seamless(image) save_image(image, resolve_path(args.output)) @@ -273,6 +301,53 @@ def resize_image_from_image(image, width, height, fit, background): return result +def colorize_alpha(image, color): + if not has_alpha(image): + raise ValueError( + "foreground color requires an image with transparency") + result = Image.new("RGBA", image.size, color[:3]) + result.putalpha(image.convert("RGBA").getchannel("A")) + return result + + +def make_seamless(image, band=32): + original_mode = image.mode + result = image.convert("RGBA") + width, height = result.size + pixels = result.load() + band = min(band, width // 4, height // 4) + + def blend_edges(first_index, last_index, length, across_length, horizontal): + for offset in range(band): + weight = 1.0 - offset / band + first = first_index(offset) + last = last_index(offset) + for across in range(across_length): + first_pos = (first, across) if horizontal else (across, first) + last_pos = (last, across) if horizontal else (across, last) + first_color = pixels[first_pos] + last_color = pixels[last_pos] + average = tuple((a + b) / 2 for a, + b in zip(first_color, last_color)) + pixels[first_pos] = tuple(round(a * (1 - weight) + b * weight) + for a, b in zip(first_color, average)) + pixels[last_pos] = tuple(round(a * (1 - weight) + b * weight) + for a, b in zip(last_color, average)) + + blend_edges(lambda offset: offset, lambda offset: width - 1 - offset, + width, height, True) + blend_edges(lambda offset: offset, lambda offset: height - 1 - offset, + height, width, False) + return result if has_alpha(image) else result.convert(original_mode) + + +def seamless_image(source, output): + with Image.open(source) as image: + image.load() + result = make_seamless(image) + save_image(result, output) + + def build_parser(): parser = argparse.ArgumentParser( description="Create and validate game image assets.") @@ -283,13 +358,16 @@ def build_parser(): generate.add_argument("--prompt", required=True) generate.add_argument("--output", required=True, help="output path, relative to the project root or absolute") - generate.add_argument("--role", choices=PROFILES, required=True) + generate.add_argument("--role", choices=API_ASPECTS, required=True) generate.add_argument( - "--model", default=generate_textures.DEFAULT_IMAGE_MODEL) + "--model", default=DEFAULT_ROLE_IMAGE_MODEL) generate.add_argument("--request-size", type=int, default=generate_textures.GEN_SIZE) generate.add_argument("--width", type=int) generate.add_argument("--height", type=int) + generate.add_argument( + "--foreground", type=parse_color, + help="replace visible RGB with this color while preserving alpha") alpha = generate.add_mutually_exclusive_group() alpha.add_argument("--transparent", dest="transparent", action="store_true") @@ -320,6 +398,13 @@ def build_parser(): resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output), args.width, args.height, args.fit, args.background)) + seamless = commands.add_parser( + "seamless", help="blend opposite edges of a texture for tiling") + seamless.add_argument("input") + seamless.add_argument("output") + seamless.set_defaults(handler=lambda args: seamless_image( + resolve_path(args.input), resolve_path(args.output))) + audit = commands.add_parser( "audit", help="classify images as good/bad using their game-asset role") audit.add_argument("paths", nargs="+", help="image files or directories") diff --git a/tools/generate_textures.py b/tools/generate_textures.py index f5e53ce..37ed849 100644 --- a/tools/generate_textures.py +++ b/tools/generate_textures.py @@ -373,23 +373,32 @@ def _api_key() -> str: def generate_image(prompt: str, model: str, size: int, - transparent: bool = False) -> Image.Image: + transparent: bool = False, + aspect_ratio: str = "1:1") -> Image.Image: """Call the OpenRouter image API and return the decoded PIL image. - Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP - bytes returned in `data[].b64_json`. The requested size is clamped to at - least GEN_SIZE because some models (e.g. seedream) reject small outputs; - callers downscale the result to MAX_DIM afterwards. When `transparent` is - true, the API is asked for a transparent background (PNG alpha). + Requests a square image at `size` px or a role-shaped image at the matching + resolution tier and decodes `data[].b64_json`. Seedream's square requests + retain the existing GEN_SIZE floor. When `transparent` is true, the API is + asked for a transparent PNG background. """ - size = max(GEN_SIZE, int(size)) + size = max(1, int(size)) + if model == DEFAULT_IMAGE_MODEL: + size = max(GEN_SIZE, size) body = { "model": model, "prompt": prompt, - "size": f"{size}x{size}", "output_format": "png", "n": 1, } + if aspect_ratio == "1:1": + body["size"] = f"{size}x{size}" + else: + body["aspect_ratio"] = aspect_ratio + body["resolution"] = next( + (tier for limit, tier in ((512, "512"), (768, "768"), (1024, "1K"), + (1536, "1.5K"), (2048, "2K"), (4096, "4K")) + if size <= limit), "4K") if transparent: body["background"] = "transparent" req = urllib.request.Request( diff --git a/tools/test_asset_pipeline.py b/tools/test_asset_pipeline.py index 47a0538..8da007b 100644 --- a/tools/test_asset_pipeline.py +++ b/tools/test_asset_pipeline.py @@ -3,7 +3,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from PIL import Image +from PIL import Image, ImageDraw import asset_pipeline @@ -51,6 +51,38 @@ class AssetPipelineTests(unittest.TestCase): self.assertEqual(asset_pipeline.audit_image( bad, "tile")["quality"], "bad") + def test_seamless_transform_makes_tile_edges_continuous(self): + image_path = self.root / "tile.png" + output = self.root / "seamless.png" + image = Image.new("RGB", (512, 512)) + for x in range(512): + for y in range(512): + image.putpixel((x, y), (x // 2, y // 2, (x + y) // 4)) + image.save(image_path) + + asset_pipeline.seamless_image(image_path, output) + + self.assertEqual(asset_pipeline.audit_image( + output, "tile")["quality"], "good") + + def test_tile_generation_applies_seamless_transform(self): + output = self.root / "generated_tile.png" + args = type("Args", (), { + "prompt": "brick", "role": "tile", "output": str(output), + "width": None, "height": None, "transparent": False, + "model": "test-model", "request_size": 1024, + })() + generated = Image.new("RGB", (512, 512), (80, 80, 80)) + for y in range(512): + generated.putpixel((0, y), (255, 0, 0)) + + with patch.object(asset_pipeline.generate_textures, "generate_image", + return_value=generated): + asset_pipeline._generate(args) + + self.assertEqual(asset_pipeline.audit_image( + output, "tile")["quality"], "good") + def test_background_audit_rejects_small_image(self): image_path = self.root / "small.png" Image.new("RGB", (640, 360)).save(image_path) @@ -63,11 +95,27 @@ class AssetPipelineTests(unittest.TestCase): def test_grayscale_vignette_is_valid_without_alpha(self): image_path = self.root / "vignette.png" - Image.new("L", (512, 512), 0).save(image_path) + image = Image.new("L", (512, 512), 0) + draw = ImageDraw.Draw(image) + for radius in range(255, -1, -1): + value = int(255 * (1 - radius / 255)) + draw.ellipse((256 - radius, 256 - radius, + 256 + radius, 256 + radius), fill=value) + image.save(image_path) self.assertEqual(asset_pipeline.audit_image( image_path, "overlay")["quality"], "good") + def test_overlay_audit_rejects_blank_transparent_image(self): + image_path = self.root / "blank_overlay.png" + Image.new("RGBA", (512, 512), (0, 0, 0, 0)).save(image_path) + + result = asset_pipeline.audit_image(image_path, "overlay") + + self.assertEqual(result["quality"], "bad") + self.assertTrue(any("near-blank frame" in error + for error in result["errors"])) + def test_icon_audit_rejects_opaque_rgba_images(self): image_path = self.root / "opaque_icon.png" Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path) @@ -78,6 +126,16 @@ class AssetPipelineTests(unittest.TestCase): self.assertTrue(any("transparency required" in error for error in result["errors"])) + def test_backdrop_audit_requires_transparency(self): + image_path = self.root / "skyline.png" + Image.new("RGB", (1024, 256), (20, 30, 40)).save(image_path) + + result = asset_pipeline.audit_image(image_path, "backdrop") + + self.assertEqual(result["quality"], "bad") + self.assertTrue(any("transparency required" in error + for error in result["errors"])) + def test_generation_reuses_existing_backend_and_targets_role_dimensions(self): output = self.root / "background.png" args = type("Args", (), { @@ -93,9 +151,43 @@ class AssetPipelineTests(unittest.TestCase): self.assertIn("Hollywood at night", backend.call_args.args[0]) self.assertIn("wide 16:9", backend.call_args.args[0]) + self.assertEqual(backend.call_args.kwargs["aspect_ratio"], "3:2") with Image.open(output) as image: self.assertEqual(image.size, (1920, 1080)) + def test_generation_can_recolor_transparent_glyph(self): + output = self.root / "icon.png" + args = type("Args", (), { + "prompt": "flashlight", "role": "icon", "output": str(output), + "width": None, "height": None, "transparent": True, + "model": "test-model", "request_size": 1024, + "foreground": (255, 255, 255, 255), + })() + generated = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + generated.putpixel((32, 32), (20, 20, 20, 255)) + + with patch.object(asset_pipeline.generate_textures, "generate_image", + return_value=generated): + asset_pipeline._generate(args) + + with Image.open(output) as image: + self.assertEqual(image.getpixel((32, 32)), (255, 255, 255, 255)) + self.assertEqual(image.getpixel((0, 0))[3], 0) + + def test_screenshot_audit_accepts_game_frame_and_rejects_blank_capture(self): + valid = self.root / "frame.png" + blank = self.root / "blank.png" + frame = Image.new("RGB", (1280, 720), (0, 0, 0)) + ImageDraw.Draw(frame).rectangle( + (640, 0, 1279, 719), fill=(255, 255, 255)) + frame.save(valid) + Image.new("RGB", (1280, 720), (0, 0, 0)).save(blank) + + self.assertEqual(asset_pipeline.audit_image( + valid, "screenshot")["quality"], "good") + self.assertEqual(asset_pipeline.audit_image( + blank, "screenshot")["quality"], "bad") + def test_role_inference_uses_game_asset_paths(self): self.assertEqual(asset_pipeline.infer_role( Path("assets/textures/icons/wand.png")), "icon") @@ -104,6 +196,12 @@ class AssetPipelineTests(unittest.TestCase): self.assertEqual(asset_pipeline.infer_role( Path("assets/textures/skyline.png")), "backdrop") + def test_role_generator_defaults_to_validated_image_model(self): + args = asset_pipeline.build_parser().parse_args([ + "generate", "--prompt", "test", "--role", "tile", "--output", "tile.png"]) + + self.assertEqual(args.model, asset_pipeline.DEFAULT_ROLE_IMAGE_MODEL) + if __name__ == "__main__": unittest.main()