Spaces:
Sleeping
Sleeping
Download app.py from GhalaDev/AI_BoardGames_Forge: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/spaces/GhalaDev/AI_BoardGames_Forge/resolve/main/app.py
- Command line
-
hf download hf://spaces/GhalaDev/AI_BoardGames_Forge/app.py
-
curl -L -o app.py https://huggingface.co/spaces/GhalaDev/AI_BoardGames_Forge/resolve/main/app.py
13.7 kB
| """ | |
| AI BoardGame Forge | |
| A Gradio web app that turns any theme into a mini card game with | |
| AI-generated text and AI-generated artwork. Built for Hugging Face Spaces. | |
| """ | |
| import json | |
| import random | |
| import urllib.parse | |
| from io import BytesIO | |
| import requests | |
| import gradio as gr | |
| import torch | |
| from PIL import Image, ImageDraw, ImageFont | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| # ------------------------------------------------------------ | |
| # Language model setup | |
| # ------------------------------------------------------------ | |
| MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct" | |
| print("Loading language model, this can take a minute on first startup...") | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_NAME, | |
| torch_dtype="auto", | |
| device_map="auto", | |
| ) | |
| print("Model loaded.") | |
| def ask_llm(system_prompt, user_prompt, max_new_tokens=500): | |
| """Sends a system prompt and a user prompt to the model and returns its text reply.""" | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| prompt_text = tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| inputs = tokenizer(prompt_text, return_tensors="pt").to(model.device) | |
| output_tokens = model.generate( | |
| **inputs, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=True, | |
| temperature=0.8, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| new_tokens = output_tokens[0][inputs["input_ids"].shape[1]:] | |
| reply = tokenizer.decode(new_tokens, skip_special_tokens=True) | |
| return reply.strip() | |
| # ------------------------------------------------------------ | |
| # Game text generator (LLM-based, with a template fallback) | |
| # ------------------------------------------------------------ | |
| SYSTEM_PROMPT = """You are a creative board game designer. | |
| Given a theme, invent a simple, fun mini card game based on it. | |
| Reply with ONLY valid JSON, no extra text and no markdown code fences, in exactly this format: | |
| { | |
| "title": "a catchy game title", | |
| "description": "2 to 3 sentences describing how the game works", | |
| "rules": "3 to 5 short, simple rules a beginner could follow, written as one string", | |
| "cards": [ | |
| {"name": "card name", "type": "card type", "effect": "short special ability"}, | |
| {"name": "card name", "type": "card type", "effect": "short special ability"}, | |
| {"name": "card name", "type": "card type", "effect": "short special ability"}, | |
| {"name": "card name", "type": "card type", "effect": "short special ability"} | |
| ] | |
| } | |
| Keep everything short, simple, and beginner-friendly. Always include exactly 4 cards.""" | |
| def extract_json(text): | |
| """Pulls out the {...} portion of a string, in case the model added extra text around it.""" | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start == -1 or end == -1: | |
| raise ValueError("No JSON object found in the model's reply.") | |
| return text[start:end + 1] | |
| def generate_game_with_llm(theme): | |
| """Asks the language model to design the game and returns it as a dictionary.""" | |
| user_prompt = f"Theme: {theme}\nDesign the card game now." | |
| raw_reply = ask_llm(SYSTEM_PROMPT, user_prompt) | |
| json_text = extract_json(raw_reply) | |
| game_data = json.loads(json_text) | |
| required_keys = {"title", "description", "rules", "cards"} | |
| if not required_keys.issubset(game_data.keys()): | |
| raise ValueError("Model reply is missing required fields.") | |
| if len(game_data["cards"]) != 4: | |
| raise ValueError("Model did not return exactly 4 cards.") | |
| return game_data | |
| # ---- Fallback generator (used only if the LLM output cannot be used) ---- | |
| TITLE_TEMPLATES = [ | |
| "{theme} Wars", | |
| "The {theme} Chronicles", | |
| "Escape from {theme}", | |
| "{theme}: The Card Game", | |
| "Rise of the {theme}", | |
| "{theme} Showdown", | |
| ] | |
| DESCRIPTION_TEMPLATES = [ | |
| "In this game, players take on the role of characters caught up in a {theme} scenario. Each player uses cards to outsmart their opponents and be the first to win.", | |
| "Players compete in a world inspired by {theme}, drawing and playing cards to gain the upper hand. Strategy and a little luck decide the winner.", | |
| ] | |
| RULES_TEMPLATE = """1. Each player starts with a hand of 4 cards. | |
| 2. Players take turns playing one card at a time. | |
| 3. When a card is played, follow the effect written on it. | |
| 4. The game ends when the deck runs out or a player reaches the target score. | |
| 5. The player with the most points, or the last one standing, wins the {theme} showdown.""" | |
| CARD_TYPES = ["Attack", "Defense", "Support", "Trick", "Special", "Boost", "Sabotage"] | |
| NAME_PARTS = [ | |
| "Guardian", "Blade", "Shadow", "Spark", "Wanderer", "Sentinel", | |
| "Rebel", "Trickster", "Champion", "Outcast", | |
| ] | |
| EFFECT_POOL = [ | |
| "Draw 2 extra cards this turn.", | |
| "Block the next attack played against you.", | |
| "Steal one card from an opponent's hand.", | |
| "Skip your opponent's next turn.", | |
| "Double your score this round.", | |
| "Gain +3 power for the rest of the game.", | |
| ] | |
| def generate_game_template(theme): | |
| """Local, non-AI backup generator used only if the language model fails.""" | |
| theme_clean = theme.strip().title() | |
| title = random.choice(TITLE_TEMPLATES).format(theme=theme_clean) | |
| description = random.choice(DESCRIPTION_TEMPLATES).format(theme=theme_clean) | |
| rules = RULES_TEMPLATE.format(theme=theme_clean) | |
| chosen_types = random.sample(CARD_TYPES, 4) | |
| cards = [] | |
| for card_type in chosen_types: | |
| name = f"{theme_clean} {random.choice(NAME_PARTS)}" | |
| effect = random.choice(EFFECT_POOL) | |
| cards.append({"name": name, "type": card_type, "effect": effect}) | |
| return {"title": title, "description": description, "rules": rules, "cards": cards} | |
| def generate_game(theme): | |
| """ | |
| Main entry point. Tries the language model first, and only falls back | |
| to the template generator if the model's reply could not be used. | |
| """ | |
| try: | |
| return generate_game_with_llm(theme) | |
| except Exception as error: | |
| print(f"LLM generation could not be used, falling back to templates. Reason: {error}") | |
| return generate_game_template(theme) | |
| # ------------------------------------------------------------ | |
| # AI artwork generator | |
| # ------------------------------------------------------------ | |
| TYPE_COLORS = { | |
| "Attack": {"bg": (178, 34, 34), "border": (255, 215, 0)}, | |
| "Defense": {"bg": (30, 60, 114), "border": (192, 192, 192)}, | |
| "Support": {"bg": (34, 87, 47), "border": (200, 255, 200)}, | |
| "Trick": {"bg": (75, 0, 130), "border": (218, 165, 32)}, | |
| "Special": {"bg": (139, 69, 19), "border": (255, 223, 0)}, | |
| "Boost": {"bg": (204, 85, 0), "border": (255, 255, 255)}, | |
| "Sabotage": {"bg": (20, 20, 20), "border": (139, 0, 0)}, | |
| } | |
| def get_font(size, bold=True): | |
| """Loads a common system font. Falls back to a basic font if not found.""" | |
| font_names = ( | |
| ["DejaVuSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf"] | |
| if bold | |
| else ["DejaVuSans.ttf", "Arial.ttf", "arial.ttf"] | |
| ) | |
| for name in font_names: | |
| try: | |
| return ImageFont.truetype(name, size) | |
| except Exception: | |
| continue | |
| return ImageFont.load_default() | |
| def make_placeholder_art(card): | |
| """A simple backup image used if the AI art request fails, or if the | |
| LLM invented a card type that has no matching artwork style.""" | |
| colors = TYPE_COLORS.get(card["type"], {"bg": (100, 100, 100)}) | |
| img = Image.new("RGB", (512, 512), colors["bg"]) | |
| draw = ImageDraw.Draw(img) | |
| font = get_font(40) | |
| draw.text((40, 236), card["type"] + " Card", font=font, fill=(255, 255, 255)) | |
| return img | |
| def generate_card_art(theme, card): | |
| """Builds an image prompt and fetches free AI art from Pollinations.ai.""" | |
| prompt = ( | |
| f"{theme} {card['name']}, {card['type']} character, " | |
| f"fantasy trading card illustration, digital painting, detailed, vibrant colors, centered" | |
| ) | |
| encoded_prompt = urllib.parse.quote(prompt) | |
| seed = random.randint(1, 999999) | |
| url = f"https://image.pollinations.ai/prompt/{encoded_prompt}?width=512&height=512&seed={seed}&nologo=true" | |
| try: | |
| response = requests.get(url, timeout=60) | |
| response.raise_for_status() | |
| return Image.open(BytesIO(response.content)).convert("RGB") | |
| except Exception as error: | |
| print(f"Could not fetch AI art for '{card['name']}', using a placeholder instead. Reason: {error}") | |
| return make_placeholder_art(card) | |
| # ------------------------------------------------------------ | |
| # Card designer | |
| # ------------------------------------------------------------ | |
| def wrap_text(text, font, max_width, draw): | |
| """Breaks a string into multiple lines so it fits inside max_width pixels.""" | |
| words = text.split() | |
| lines = [] | |
| current = "" | |
| for word in words: | |
| test_line = (current + " " + word).strip() | |
| box = draw.textbbox((0, 0), test_line, font=font) | |
| if box[2] - box[0] <= max_width: | |
| current = test_line | |
| else: | |
| if current: | |
| lines.append(current) | |
| current = word | |
| if current: | |
| lines.append(current) | |
| return lines | |
| def draw_card(card, art_image, theme): | |
| """Composes the final card image: background, artwork, title bar, type badge, effect text. | |
| Card type may come from the LLM and not match a preset type; TYPE_COLORS.get() | |
| below falls back to a neutral gray frame in that case.""" | |
| width, height = 500, 700 | |
| colors = TYPE_COLORS.get(card["type"], {"bg": (60, 60, 60), "border": (255, 255, 255)}) | |
| card_img = Image.new("RGB", (width, height), colors["bg"]) | |
| draw = ImageDraw.Draw(card_img) | |
| draw.rectangle([0, 0, width - 1, height - 1], outline=colors["border"], width=10) | |
| title_font = get_font(28) | |
| draw.rectangle([15, 15, width - 15, 80], fill=(0, 0, 0)) | |
| title_box = draw.textbbox((0, 0), card["name"], font=title_font) | |
| title_x = (width - (title_box[2] - title_box[0])) / 2 | |
| draw.text((title_x, 25), card["name"], font=title_font, fill=(255, 255, 255)) | |
| art_box = (20, 90, width - 20, 430) | |
| art_resized = art_image.resize((art_box[2] - art_box[0], art_box[3] - art_box[1])) | |
| card_img.paste(art_resized, (art_box[0], art_box[1])) | |
| draw.rectangle(art_box, outline=colors["border"], width=4) | |
| type_font = get_font(20) | |
| draw.rectangle([20, 440, 200, 480], fill=colors["border"]) | |
| draw.text((30, 448), card["type"], font=type_font, fill=(0, 0, 0)) | |
| effect_font = get_font(18, bold=False) | |
| draw.rectangle([20, 500, width - 20, height - 40], fill=(255, 255, 255)) | |
| lines = wrap_text(card["effect"], effect_font, width - 60, draw) | |
| y = 515 | |
| for line in lines: | |
| draw.text((35, y), line, font=effect_font, fill=(0, 0, 0)) | |
| y += 24 | |
| footer_font = get_font(13, bold=False) | |
| draw.text((25, height - 30), theme.title(), font=footer_font, fill=colors["border"]) | |
| return card_img | |
| # ------------------------------------------------------------ | |
| # Full pipeline | |
| # ------------------------------------------------------------ | |
| def forge_full_game(theme): | |
| """Full pipeline: theme -> LLM-generated game text -> AI artwork -> designed card images.""" | |
| print(f"Generating game for theme: {theme}") | |
| game_data = generate_game(theme) | |
| print(f"Game created: {game_data['title']}") | |
| card_images = [] | |
| for i, card in enumerate(game_data["cards"], start=1): | |
| print(f"Generating artwork for card {i} of 4: {card['name']}") | |
| art = generate_card_art(theme, card) | |
| card_img = draw_card(card, art, theme) | |
| card_images.append(card_img) | |
| print("Finished generating game and cards.") | |
| return game_data, card_images | |
| # ------------------------------------------------------------ | |
| # Gradio web app | |
| # ------------------------------------------------------------ | |
| EXAMPLE_THEMES = [ | |
| "Space Cats", | |
| "Ancient Pirates", | |
| "Escaping a Corporate Office", | |
| "Medieval Kingdom", | |
| "Desert Survivors", | |
| "Robot Uprising", | |
| ] | |
| def build_game(theme): | |
| """Connected to the Generate button. Runs the pipeline and formats the output.""" | |
| if not theme or not theme.strip(): | |
| return "Please enter a theme first.", [] | |
| game_data, card_images = forge_full_game(theme) | |
| info_text = ( | |
| f"## {game_data['title']}\n\n" | |
| f"**Description:** {game_data['description']}\n\n" | |
| f"**Rules:**\n\n{game_data['rules']}" | |
| ) | |
| gallery_items = [ | |
| (img, card["name"]) for img, card in zip(card_images, game_data["cards"]) | |
| ] | |
| return info_text, gallery_items | |
| with gr.Blocks(title="AI BoardGame Forge") as demo: | |
| gr.Markdown("# AI BoardGame Forge") | |
| gr.Markdown( | |
| "Enter a theme, or select one of the example themes below, " | |
| "then click Generate Game to create a mini card game with AI-generated text and artwork." | |
| ) | |
| theme_input = gr.Textbox( | |
| label="Theme", | |
| placeholder="e.g. Space Cats, Ancient Pirates, Escaping a Corporate Office", | |
| ) | |
| gr.Examples( | |
| examples=EXAMPLE_THEMES, | |
| inputs=theme_input, | |
| label="Example themes", | |
| ) | |
| generate_button = gr.Button("Generate Game") | |
| game_info_output = gr.Markdown() | |
| card_gallery_output = gr.Gallery( | |
| label="Generated Cards", | |
| columns=4, | |
| height="auto", | |
| ) | |
| generate_button.click( | |
| fn=build_game, | |
| inputs=theme_input, | |
| outputs=[game_info_output, card_gallery_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |