// Package resolution provides a profile system for managing screen regions across different resolutions. // // Each game plugin can register resolution profiles that define named screen regions // for different resolutions. This allows the engine to work with games at various // resolutions without hardcoding pixel coordinates. package resolution import ( "fmt" "image" "sort" ) // Profile defines screen regions for a specific resolution. type Profile struct { Width int `yaml:"width"` // Screen width in pixels Height int `yaml:"height"` // Screen height in pixels Label string `yaml:"label"` // Human-readable label (e.g., "1080p", "720p") Regions map[string]image.Rectangle `yaml:"regions"` // Named screen regions } // Registry holds profiles per game per resolution. type Registry struct { // profiles[gameID][resolution_key] = Profile profiles map[string]map[string]*Profile } // NewRegistry creates a new resolution profile registry. func NewRegistry() *Registry { return &Registry{ profiles: make(map[string]map[string]*Profile), } } // Register adds a resolution profile for a specific game. func (r *Registry) Register(gameID string, profile *Profile) error { if profile == nil { return fmt.Errorf("profile cannot be nil") } if profile.Width <= 0 || profile.Height <= 0 { return fmt.Errorf("invalid resolution: %dx%d", profile.Width, profile.Height) } if r.profiles[gameID] == nil { r.profiles[gameID] = make(map[string]*Profile) } key := resolutionKey(profile.Width, profile.Height) r.profiles[gameID][key] = profile return nil } // Get returns the profile for a game and resolution, or error if unsupported. func (r *Registry) Get(gameID string, width, height int) (*Profile, error) { gameProfiles, exists := r.profiles[gameID] if !exists { return nil, fmt.Errorf("no profiles registered for game %q", gameID) } key := resolutionKey(width, height) profile, exists := gameProfiles[key] if !exists { return nil, fmt.Errorf("no profile for game %q at resolution %dx%d", gameID, width, height) } return profile, nil } // GetRegion returns a named region for a game and resolution. func (r *Registry) GetRegion(gameID string, width, height int, regionName string) (image.Rectangle, error) { profile, err := r.Get(gameID, width, height) if err != nil { return image.Rectangle{}, err } region, exists := profile.Regions[regionName] if !exists { return image.Rectangle{}, fmt.Errorf("region %q not found in profile for %s at %dx%d", regionName, gameID, width, height) } return region, nil } // SupportedResolutions returns resolutions available for a game. func (r *Registry) SupportedResolutions(gameID string) []image.Point { gameProfiles, exists := r.profiles[gameID] if !exists { return nil } var resolutions []image.Point for _, profile := range gameProfiles { resolutions = append(resolutions, image.Point{X: profile.Width, Y: profile.Height}) } // Sort by resolution (width first, then height) sort.Slice(resolutions, func(i, j int) bool { if resolutions[i].X != resolutions[j].X { return resolutions[i].X < resolutions[j].X } return resolutions[i].Y < resolutions[j].Y }) return resolutions } // ListGames returns all game IDs that have registered profiles. func (r *Registry) ListGames() []string { var games []string for gameID := range r.profiles { games = append(games, gameID) } sort.Strings(games) return games } // GetProfiles returns all profiles for a specific game. func (r *Registry) GetProfiles(gameID string) []*Profile { gameProfiles, exists := r.profiles[gameID] if !exists { return nil } var profiles []*Profile for _, profile := range gameProfiles { profiles = append(profiles, profile) } // Sort by resolution sort.Slice(profiles, func(i, j int) bool { if profiles[i].Width != profiles[j].Width { return profiles[i].Width < profiles[j].Width } return profiles[i].Height < profiles[j].Height }) return profiles } // ValidateProfile checks if a profile is valid and complete. func (r *Registry) ValidateProfile(profile *Profile) error { if profile == nil { return fmt.Errorf("profile is nil") } if profile.Width <= 0 || profile.Height <= 0 { return fmt.Errorf("invalid resolution: %dx%d", profile.Width, profile.Height) } if profile.Label == "" { return fmt.Errorf("profile label is required") } if profile.Regions == nil { return fmt.Errorf("profile regions map is nil") } // Validate that all regions are within screen bounds screenBounds := image.Rect(0, 0, profile.Width, profile.Height) for regionName, region := range profile.Regions { if !region.In(screenBounds) { return fmt.Errorf("region %q is outside screen bounds: %v not in %v", regionName, region, screenBounds) } if region.Empty() { return fmt.Errorf("region %q is empty: %v", regionName, region) } } return nil } // RegisterMultiple registers multiple profiles for a game. func (r *Registry) RegisterMultiple(gameID string, profiles []*Profile) error { for _, profile := range profiles { if err := r.ValidateProfile(profile); err != nil { return fmt.Errorf("invalid profile %s: %w", profile.Label, err) } if err := r.Register(gameID, profile); err != nil { return fmt.Errorf("failed to register profile %s: %w", profile.Label, err) } } return nil } // Clone creates a deep copy of a profile. func (p *Profile) Clone() *Profile { clone := &Profile{ Width: p.Width, Height: p.Height, Label: p.Label, Regions: make(map[string]image.Rectangle), } for name, region := range p.Regions { clone.Regions[name] = region } return clone } // HasRegion checks if a profile contains a named region. func (p *Profile) HasRegion(name string) bool { _, exists := p.Regions[name] return exists } // ListRegions returns all region names in the profile. func (p *Profile) ListRegions() []string { var names []string for name := range p.Regions { names = append(names, name) } sort.Strings(names) return names } // AspectRatio returns the aspect ratio of the profile resolution. func (p *Profile) AspectRatio() float64 { return float64(p.Width) / float64(p.Height) } // IsWidescreen returns true if the profile is widescreen (16:9 or wider). func (p *Profile) IsWidescreen() bool { return p.AspectRatio() >= 16.0/9.0 } // ScaleFrom creates a new profile by scaling regions from another resolution. func (p *Profile) ScaleFrom(source *Profile) *Profile { if source == nil { return nil } scaleX := float64(p.Width) / float64(source.Width) scaleY := float64(p.Height) / float64(source.Height) scaled := &Profile{ Width: p.Width, Height: p.Height, Label: p.Label, Regions: make(map[string]image.Rectangle), } for name, region := range source.Regions { scaled.Regions[name] = image.Rect( int(float64(region.Min.X)*scaleX), int(float64(region.Min.Y)*scaleY), int(float64(region.Max.X)*scaleX), int(float64(region.Max.Y)*scaleY), ) } return scaled } // resolutionKey generates a unique key for a resolution. func resolutionKey(width, height int) string { return fmt.Sprintf("%dx%d", width, height) } // ParseResolution parses a resolution string like "1920x1080". func ParseResolution(s string) (width, height int, err error) { n, err := fmt.Sscanf(s, "%dx%d", &width, &height) if err != nil { return 0, 0, fmt.Errorf("invalid resolution format %q: %w", s, err) } if n != 2 { return 0, 0, fmt.Errorf("invalid resolution format %q: expected WIDTHxHEIGHT", s) } if width <= 0 || height <= 0 { return 0, 0, fmt.Errorf("invalid resolution: %dx%d", width, height) } return width, height, nil } // FormatResolution formats width and height as a resolution string. func FormatResolution(width, height int) string { return fmt.Sprintf("%dx%d", width, height) }