Newer
Older
EARS-LINUX / program / MapWindow.cs
using System;
using System.IO;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;

public class MapWindow : IDisposable
{
    // Form2 view offsets — L size
    private const float FRONT_OFFSET_X = 25.0f,  FRONT_OFFSET_Y = 50.0f;
    private const float BACK_OFFSET_X  = 20.0f,  BACK_OFFSET_Y  = 20.0f;
    private const float RIGHT_OFFSET_X = 10.0f,  RIGHT_OFFSET_Y = -150.0f;
    private const float LEFT_OFFSET_X  = -50.0f, LEFT_OFFSET_Y  = -150.0f;

    private const float FRONT_SIZE = 1.0f, BACK_SIZE = 0.5f,
                        RIGHT_SIZE = 1.0f, LEFT_SIZE = 1.0f;

    // XL deltas
    private const float XL_FRONT_OFFSET_X = -28.0f, XL_FRONT_OFFSET_Y = 24.0f;
    private const float XL_BACK_OFFSET_X  = 8.0f,   XL_BACK_OFFSET_Y  = -15.0f;
    private const float XL_RIGHT_OFFSET_X = -41.0f, XL_RIGHT_OFFSET_Y = 8.0f;
    private const float XL_LEFT_OFFSET_X  = 54.0f,  XL_LEFT_OFFSET_Y  = 0.0f;

    private const int CircleDiameter = 50;
    // ── glade widgets ──
    [UI] private DrawingArea  MapArea           = null;
    [UI] private Label        MapTitleLabel     = null;
    [UI] private Label        MapCaseLabel      = null;
    [UI] private Button       MapBackButton     = null;
    [UI] private RadioButton  BodySkeletonRadio = null;
    [UI] private RadioButton  BodyJacketRadio   = null;
    [UI] private ComboBoxText JacketSizeCombo   = null;
    [UI] private RadioButton  ViewFrontRadio    = null;
    [UI] private RadioButton  ViewBackRadio     = null;
    [UI] private RadioButton  ViewRightRadio    = null;
    [UI] private RadioButton  ViewLeftRadio     = null;
    [UI] private CheckButton  HideCircleCheck   = null;

    private readonly Language    _lang;
    private readonly SoundLooper _sound = new SoundLooper();

    // ── state ──
    private Gdk.Pixbuf _body;          // background, native size
    private Gdk.Pixbuf _map;           // 546x546, black keyed out — also the audio reference
    private CaseDefinition _case;
    private string _view = "front";

    private const int    MapSize  = 546;   // Form2: new Bitmap(mapImage, 546, 546)
    private const double MapAlpha = 0.7;   // Form2: CreateOverlayImage(..., 0.7f, ...)

    public event EventHandler BackRequested;

    private JacketData _jacket;
    private System.Collections.Generic.List<float> _highlighted = new System.Collections.Generic.List<float>();
    private static bool _listed;

    public MapWindow(Builder builder, Language language)
    {
        builder.Autoconnect(this);

        _jacket = JacketData.Load();
        Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded);

        if (MapArea == null || MapTitleLabel == null || MapBackButton == null)
            throw new InvalidOperationException(
                "Glade id mismatch — MapArea=" + (MapArea != null) +
                " MapTitleLabel=" + (MapTitleLabel != null) +
                " MapBackButton=" + (MapBackButton != null));

        _lang = language;

        MapArea.Drawn         += OnMapDrawn;
        MapBackButton.Clicked += OnBackClicked;

        BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); };
        BodyJacketRadio.Toggled   += (s, e) => { if (BodyJacketRadio.Active)   ReloadBody(); };
        JacketSizeCombo.Changed += (s, e) => { ReloadBody(); MapArea.QueueDraw(); };

        HookView(ViewFrontRadio, "front");
        HookView(ViewBackRadio,  "back");
        HookView(ViewRightRadio, "right");
        HookView(ViewLeftRadio,  "left");

        HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw();

        _sound.Log += msg => Logger.Write("sound", msg);
    }

    private void HookView(RadioButton rb, string view)
    {
        if (rb == null) return;
        rb.Toggled += (s, e) => { if (rb.Active) SetView(view); };
    }

    // ── public API ─────────────────────────────────────────────────────

    public void ShowCase(CaseDefinition def, string title)
    {
        _case = def;
        _view = "front";
        if (ViewFrontRadio != null) ViewFrontRadio.Active = true;

        MapTitleLabel.Text = title ?? "";
        MapCaseLabel.Text  = title ?? "-";

        DisposePixbufs();
        _body = LoadBody();
        _map  = LoadMap(MapPathForView());

        MapArea.QueueDraw();
        _sound.Start(SoundWindow.AppFile(def.SoundPath));

        Logger.Write("case", "open: " + title + " | map=" + MapPathForView());
    }

    public void StopSession()
    {
        _sound.Stop();
        Logger.Write("case", "closed");
    }

    /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited.
    public Gdk.Pixbuf AudioReference { get { return _map; } }

    // ── the paint ──────────────────────────────────────────────────────

    private void OnMapDrawn(object o, DrawnArgs args)
    {
        var cr = args.Cr;
        int w  = MapArea.AllocatedWidth;
        int h  = MapArea.AllocatedHeight;

        if (_body == null) { args.RetVal = true; return; }

        // ONE scale factor, derived from the body — this is what keeps the
        // overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom.
        double s  = Math.Min((double)w / _body.Width, (double)h / _body.Height);
        double ox = (w - _body.Width  * s) / 2.0;
        double oy = (h - _body.Height * s) / 2.0;

        cr.Save();
        cr.Translate(ox, oy);
        cr.Scale(s, s);                 // everything below is in BODY PIXELS

        Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
        cr.Paint();

        if (_map != null)
        {
            // CreateOverlayImage(position: null) == centre the MAP on the body
            double mx = (_body.Width  - _map.Width)  / 2.0;
            double my = (_body.Height - _map.Height) / 2.0;

            Gdk.CairoHelper.SetSourcePixbuf(cr, _map, mx, my);
            cr.PaintWithAlpha(MapAlpha);
        }

        if (!HideCircleCheck.Active)
            DrawCircles(cr);            // body-pixel coords; transform does the rest

        cr.Restore();
        args.RetVal = true;
    }

    private void DrawCircles(Cairo.Context cr)
    {
        if (_jacket == null || _body == null) return;

        var circles = _jacket.Get(JacketSize, _view);
        if (circles.Count == 0) return;

        // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546
        int panelW = _body.Width;
        int panelH = _body.Height;

        float scale = ScalingFactor(circles, panelW, panelH);

        float minX = float.MaxValue, maxX = float.MinValue;
        float minY = float.MaxValue, maxY = float.MinValue;
        foreach (var c in circles)
        {
            if (c.X < minX) minX = c.X;
            if (c.X > maxX) maxX = c.X;
            if (c.Y < minY) minY = c.Y;
            if (c.Y > maxY) maxY = c.Y;
        }

        float offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
        float offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale;

        cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);

        foreach (var c in circles)
            DrawOneCircle(cr, c, scale, offsetX, offsetY);
    }

    private float ScalingFactor(System.Collections.Generic.List<Circle> circles, int panelW, int panelH)
    {
        if (circles.Count == 0) return 1.0f;

        int availW = panelW - 40, availH = panelH - 40;
        if (_view == "front") { availW = panelW - 100; availH = panelH - 100; }

        float minX = float.MaxValue, maxX = float.MinValue;
        float minY = float.MaxValue, maxY = float.MinValue;
        foreach (var c in circles)
        {
            if (c.X < minX) minX = c.X;
            if (c.X > maxX) maxX = c.X;
            if (c.Y < minY) minY = c.Y;
            if (c.Y > maxY) maxY = c.Y;
        }

        const float circleRadius = 2.1f / 2;
        float dataW = (maxX - minX) + circleRadius * 2;
        float dataH = (maxY - minY) + circleRadius * 2;

        if (dataW <= 0 || dataH <= 0) return 1.0f;

        return Math.Min(availW / dataW, availH / dataH) * 0.9f;
    }

    private void DrawOneCircle(Cairo.Context cr, Circle c, float scale, float ox, float oy)
    {
        float x, y, sizeScale;

        switch (_view)
        {
            case "back":
                x = c.X * scale + ox - BACK_OFFSET_X;
                y = c.Y * scale + oy - BACK_OFFSET_Y;
                sizeScale = BACK_SIZE;
                break;
            case "right":
                x = c.X * scale + ox - RIGHT_OFFSET_X;
                y = c.Y * scale + oy - RIGHT_OFFSET_Y;
                sizeScale = RIGHT_SIZE;
                break;
            case "left":
                x = c.X * scale + ox - LEFT_OFFSET_X;
                y = c.Y * scale + oy - LEFT_OFFSET_Y;
                sizeScale = LEFT_SIZE;
                break;
            default:
                x = c.X * scale + ox - FRONT_OFFSET_X;
                y = c.Y * scale + oy - FRONT_OFFSET_Y;
                sizeScale = FRONT_SIZE;
                break;
        }

        if (JacketSize == "XL")
        {
            switch (_view)
            {
                case "back":  x += XL_BACK_OFFSET_X;  y += XL_BACK_OFFSET_Y;  break;
                case "right": x += XL_RIGHT_OFFSET_X; y += XL_RIGHT_OFFSET_Y; break;
                case "left":  x += XL_LEFT_OFFSET_X;  y += XL_LEFT_OFFSET_Y;  break;
                default:      x += XL_FRONT_OFFSET_X; y += XL_FRONT_OFFSET_Y; break;
            }
        }

        double d = CircleDiameter * scale * sizeScale;
        double r = d / 2;

        bool hot = false;
        foreach (float n in _highlighted)
            if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; }

        cr.NewPath();
        cr.Arc(x, y, r, 0, 2 * Math.PI);

        if (hot)
        {
            // cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0);   // Color.FromArgb(255,235,59)
            // cr.Arc(x, y, r, 0, 2 * Math.PI);
            // cr.FillPreserve();
            cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0);
            cr.FillPreserve();
        }
        // else
        // {
        //     cr.Arc(x, y, r, 0, 2 * Math.PI);
        // }

        cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0);           // BorderColor = Gray
        cr.LineWidth = Math.Max(1.0, scale);
        cr.Stroke();

        string text = (c.Number % 1 == 0)
            ? ((int)c.Number).ToString()
            : c.Number.ToString("F1");

        cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4)));
        var ext = cr.TextExtents(text);
        cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2);
        cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0);           // TextColor
        cr.ShowText(text);
        cr.NewPath();
    }

    // ── loading ────────────────────────────────────────────────────────

    private void SetView(string view)
    {
        if (_view == view) return;
        _view = view;

        ReloadBody();

        if (_map != null) { _map.Dispose(); _map = null; }
        _map = LoadMap(MapPathForView());

        MapArea.QueueDraw();
        Logger.Write("view", "-> " + view);
    }

    private void ReloadBody()
    {
        if (_body != null) { _body.Dispose(); _body = null; }
        _body = LoadBody();
        MapArea.QueueDraw();
    }

    private Gdk.Pixbuf LoadBody()
    {
         string stem = BodyStem();
        string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png"));

        if (!File.Exists(full))
        {
            Logger.Write("body", "not found: map/" + stem + ".png");
            ListFolderOnce("map");
            return null;
        }

        return Load(full);
        // string type = (BodyJacketRadio != null && BodyJacketRadio.Active) ? "jacket" : "skel";
        // string size = (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L";

        // // Adjust to your actual body/ filenames.
        // string rel = Path.Combine("body", type + "_" + _view + "_" + size + ".png");
        // return Load(SoundWindow.AppFile(rel));
    }
    private static void ListFolderOnce(string rel)
    {
        if (_listed) return;
        _listed = true;

        string dir = SoundWindow.AppFile(rel);
        if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; }

        foreach (string f in Directory.GetFiles(dir))
            Logger.Write("body", "found: " + Path.GetFileName(f));
    }

    private string BodyStem()
    {
        bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active);

        if (skeleton)
        {
            switch (_view)
            {
                case "back":  return "jacketBackBody";
                case "right": return "jacketRightBody";
                case "left":  return "jacketLeftBody";
                default:      return "jacketFrontBody";
            }
        }

        switch (_view)
        {
            case "back":  return "jacketBack";
            case "right": return "jacketRight";
            case "left":  return "jacketLeft";
            default:      return "jacketFront";
        }
    }

    private string MapPathForView()
    {
        if (_case == null) return null;

        string rel;
        switch (_view)
        {
            case "back":  rel = _case.MapBack;  break;
            case "right": rel = _case.MapRight; break;
            case "left":  rel = _case.MapLeft;  break;
            default:      rel = _case.MapFront; break;
        }

        // Form2 falls back to a mirrored front map when there's no dedicated back.
        if (string.IsNullOrEmpty(rel)) rel = _case.MapFront;

        return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel);
    }

    private static Gdk.Pixbuf Load(string path)
    {
        if (string.IsNullOrEmpty(path) || !File.Exists(path))
        {
            Logger.Write("map", "image not found: " + path);
            return null;
        }

        try { return new Gdk.Pixbuf(path); }
        catch (Exception ex)
        {
            Logger.Write("map", "load failed " + path + ": " + ex.Message);
            return null;
        }
    }

    private static Gdk.Pixbuf LoadMap(string path)
    {
        var raw = Load(path);
        if (raw == null) return null;

        // Key out black FIRST, then scale. Scaling first leaves near-black
        // edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo.
        var keyed = raw.AddAlpha(true, 0, 0, 0);
        raw.Dispose();

        var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear);
        keyed.Dispose();
        return sized;
    }

    // ── plumbing ───────────────────────────────────────────────────────

    private void OnBackClicked(object sender, EventArgs e)
    {
        StopSession();
        var h = BackRequested;
        if (h != null) h(this, EventArgs.Empty);
    }

    private void DisposePixbufs()
    {
        if (_body != null) { _body.Dispose(); _body = null; }
        if (_map  != null) { _map.Dispose();  _map  = null; }
    }

    public void Dispose()
    {
        _sound.Dispose();
        DisposePixbufs();
    }

    private string JacketSize
    {
        get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; }
    }
}