Compare commits

..

2 Commits

Author SHA1 Message Date
a31fd760d9 Enhance game card styles and add image handling in the home view
- Updated styles for game cards, including hover and focus states.
- Integrated image loading for game cards, scaling images appropriately.
- Modified GameAccordion to display images and improved layout handling.
- Added image path management in ROM verification logic.
2025-05-21 12:22:08 -07:00
51e45d7600 Update project dependencies in requirements.txt 2025-05-21 12:21:04 -07:00
4 changed files with 190 additions and 46 deletions

View File

@ -22,6 +22,7 @@ Header, .header {
color: #ed7d3a;
text-style: bold;
padding: 1 2;
border: solid #3a9bed;
}
/* === Buttons === */
@ -33,6 +34,7 @@ Button {
padding: 1 2;
margin: 1;
content-align: center middle;
text-style: bold;
}
Button:hover {
@ -136,11 +138,81 @@ Input:focus {
width: 100%;
}
.confirm_button {
align-horizontal: center;
align-vertical: top;
margin-top: 1;
.game_card {
width: auto;
height: auto;
max-width: 60;
margin: 1;
padding: 1;
background: #1f1f1f;
color: #ed7d3a;
border: solid #3a9bed;
content-align: center middle;
text-style: bold;
text-align: center;
}
.game_card:hover {
background: #2a2a2a;
color: #ed7d3a;
}
.game_card:focus {
background: #2a2a2a;
border: solid #ed7d3a;
}
.game_card:disabled {
background: #1f1f1f;
color: #3a9bed;
}
.game_card_clicked {
background: #ed7d3a;
color: #0e0e0e;
}
.offset_card {
width: 11;
height: auto;
visibility: hidden;
}
/* ✅ Keep this for consistent row layout */
.rom_row {
layout: horizontal;
align-horizontal: center;
padding: 1 1;
}
.rom_row {
layout: horizontal;
align-horizontal: center;
padding: 1 2; /* maybe increase vertical padding */
}
.info_confirm_row {
layout: horizontal;
width: 100%;
height: auto;
padding: 1 2;
align-vertical: top;
}
#confirm_button {
width: 20%;
height: auto;
content-align: center middle;
}
.confirm_button:hover {
background: #ed7d3a;
color: #0e0e0e;
}
.game_info {
@ -148,3 +220,7 @@ Input:focus {
width: 100%;
color: #ed7d3a;
}
#game_info_box {
width: 80%;
}

View File

@ -27,11 +27,13 @@ GAME_FILES = {
"X-Men vs. Street Fighter": "xmvsf.zip",
}
def get_image_path(rom_file: str) -> str:
"""Returns relative image path for a given ROM zip."""
base_name = os.path.splitext(rom_file)[0]
return f"agentm/assets/game_images/{base_name}.jpg"
def get_verified_roms():
verified = []
@ -51,6 +53,8 @@ def get_verified_roms():
cached = get_cached_rom(rom_file)
if cached and cached["verified"]:
# Add image path to runtime version even if not stored in DB
cached["image_path"] = get_image_path(rom_file)
log_with_caller("info", f"✓ Cached ROM is valid: {title} ({rom_file})")
verified.append(cached)
continue
@ -68,6 +72,7 @@ def get_verified_roms():
sha256_match = re.search(r"sha256\s*=\s*([a-f0-9]{64})", result.stdout, re.IGNORECASE)
sha256 = sha256_match.group(1) if sha256_match else ""
game_id = rom_file.replace(".zip", "")
image_path = get_image_path(rom_file)
block = extract_game_block(full_list_output, game_id)
difficulty_min = extract_line_int(block, "Difficulty levels", index=0)
@ -84,13 +89,32 @@ def get_verified_roms():
difficulty_max=difficulty_max,
characters=characters,
keywords=keywords
# Note: if your DB schema supports image_path, include it here
)
verified.append({
"title": title,
"rom_file": rom_file,
"game_id": game_id,
"sha256": sha256,
"difficulty_min": difficulty_min,
"difficulty_max": difficulty_max,
"characters": characters,
"keywords": keywords,
"verified": True,
"image_path": image_path,
})
log_with_caller("info", f"✓ Verified and cached: {title} ({rom_file})")
else:
log_with_caller("error", f"✗ Invalid ROM: {title} ({rom_file})")
# If using DB records, append image paths before returning
if not verified:
verified = get_all_verified_roms()
for v in verified:
v["image_path"] = get_image_path(v["rom_file"])
log_with_caller("info", f"ROM verification completed: {len(verified)} valid game(s)")
return verified

View File

@ -1,11 +1,16 @@
from textual.screen import Screen
from textual.widgets import Static, Button
from textual.containers import Vertical, Horizontal
from textual.containers import Vertical, Horizontal, HorizontalScroll
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
import asyncio
from math import ceil
from rich.panel import Panel
from rich.console import Group
from PIL import Image, ImageEnhance, ImageFilter
import os
from rich_pixels import Pixels
from rich_pixels._renderer import HalfcellRenderer
from agentm.utils.logger import log_with_caller
from agentm.logic.roms import get_verified_roms, GAME_FILES
@ -24,27 +29,62 @@ class ProgressWidget(Widget):
return f"[bold cyan]{self.message}[/bold cyan]"
class GameAccordion(Vertical):
class GameAccordion(Static):
def __init__(self, title: str, rom_file: str, metadata: dict, parent_view):
safe_id = rom_file.replace(".", "_").replace("-", "_")
super().__init__(id=f"accordion_{safe_id}")
self.title = title
self.rom_file = rom_file
self.safe_id = safe_id
self.metadata = metadata
self.parent_view = parent_view
self.safe_id = rom_file.replace(".", "_").replace("-", "_")
def compose(self):
yield Button(f"{self.title}", id=f"btn_{self.safe_id}", classes="game_button")
image_path = os.path.abspath(self.metadata.get("image_path", ""))
self.image_renderable = self.load_image_scaled(image_path)
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == f"btn_{self.safe_id}":
super().__init__(id=f"accordion_{self.safe_id}", classes="game_card")
def load_image_scaled(self, path: str):
try:
with Image.open(path) as img:
if img.mode != "RGBA":
img = img.convert("RGBA")
scale_factor = 0.10
target_width = int(img.width * scale_factor)
target_height = int(img.height * scale_factor)
resized = img.resize(
(target_width, target_height),
resample=Image.Resampling.LANCZOS
)
resized = resized.filter(ImageFilter.UnsharpMask(radius=1, percent=150, threshold=3))
self._render_width = target_width
self._render_height = target_height // 2
return Pixels.from_image(
resized,
renderer=HalfcellRenderer(default_color="black"),
)
except Exception as e:
self._render_width = 24
self._render_height = 16
return f"[red]Failed to load image[/red]\n[dim]{e}]"
def render(self):
return Panel(
Group(
self.image_renderable,
"", # spacer
f"[center][bold orange1][u]{self.title.upper()}[/u][/bold orange1][/center]\n",
),
border_style="bright_magenta",
padding=(1, 2),
expand=False
)
async def on_click(self):
await self.display_info()
self.parent_view.highlight_selected(self)
async def display_info(self):
log_with_caller("debug", f"Showing shared info for {self.rom_file}")
meta = self.metadata
log_with_caller("debug", f"Showing shared info for {self.rom_file}")
info_lines = [
f"[b]Title:[/b] {meta['title']}",
f"[b]Game ID:[/b] {meta['game_id']}",
@ -53,7 +93,6 @@ class GameAccordion(Vertical):
f"[b]Keywords:[/b] {', '.join(meta.get('keywords', []))}",
f"[b]SHA256:[/b] {meta['sha256']}",
]
self.parent_view.shared_info_box.update("\n".join(info_lines))
self.parent_view.shared_confirm_button.label = f"✅ Confirm {meta['title']}"
self.parent_view.selected_game = meta
@ -62,6 +101,12 @@ class GameAccordion(Vertical):
class HomeView(Screen):
BINDINGS = [("escape", "app.quit", "Quit")]
def highlight_selected(self, selected_widget: GameAccordion):
for card in self.rom_scroll_row.children:
if isinstance(card, GameAccordion):
card.remove_class("game_card_clicked")
selected_widget.add_class("game_card_clicked")
def compose(self):
self.logo = Static(
"[b bright_magenta]\n\n"
@ -69,10 +114,10 @@ class HomeView(Screen):
"██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n"
"███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n"
"██╔══██║ ██║ ██║ ██╔══╝ ██║╚████║ ██║ ██║╚██╔╝██║\n"
"██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║ ██║\n"
"╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝\n[/]",
"██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║\n"
"╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝\n[/]",
classes="header",
expand=True,
expand=False,
)
self.progress_text = ProgressWidget()
@ -111,35 +156,33 @@ class HomeView(Screen):
self.shared_info_box = Static("", id="game_info_box", classes="game_info")
self.shared_confirm_button = Button("✅ Confirm", id="confirm_button", classes="confirm_button")
self.rom_scroll_row = HorizontalScroll(id="rom_scroll_row", classes="rom_row")
await self.mount(
Vertical(
Static("🎮 Welcome to Agent M", classes="header"),
self.logo,
Static("🎮 Welcome to Agent M", classes="header", expand=False),
Static(
"This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.\n\n"
"Verified ROMs are shown below. Click one to view game info and confirm selection.",
classes="body"
),
Static(f"✅ Found {len(verified_roms)} valid ROM(s).", id="status_message"),
Vertical(
Horizontal(id="rom_row_1", classes="rom_row"),
Horizontal(id="rom_row_2", classes="rom_row"),
id="rom_rows",
classes="rom_rows_container"
classes="body",
expand=False
),
self.rom_scroll_row,
Horizontal(
self.shared_info_box,
self.shared_confirm_button,
id="info_row",
classes="info_confirm_row",
),
id="home_screen_container",
classes="centered_layout"
)
)
per_row = ceil(len(verified_roms) / 2)
rows = [verified_roms[:per_row], verified_roms[per_row:]]
for i, row in enumerate(rows, start=1):
rom_row = self.query_one(f"#rom_row_{i}", Horizontal)
for rom in row:
await rom_row.mount(GameAccordion(
for rom in verified_roms:
await self.rom_scroll_row.mount(GameAccordion(
title=rom["title"],
rom_file=rom["rom_file"],
metadata=rom,
@ -149,4 +192,3 @@ class HomeView(Screen):
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm_button" and self.selected_game:
await self.app.push_screen("training", self.selected_game)

View File

@ -8,3 +8,5 @@ stable-baselines3
tensorboard
requests
sqlite3
rich-pixels
pillow