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"; }
}
}