# -*- coding: utf-8 -*-
"""
目标练习器 · 鼠标点击训练 / 反应速度测试（tkinter 版）
单文件、纯标准库，无需第三方依赖。

模式：
  限时挑战 — 规定时间内尽可能多地命中
  定量挑战 — 打完指定数量目标，比总用时与平均反应
  生存挑战 — 目标会自行消失（超时算 miss），漏 5 个即结束

操作：鼠标左键点圆点；Esc 结束本局；结算页可再来一局 / 重玩同参数。
"""

import json
import math
import os
import platform
import random
import time
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox, ttk

SAVE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aim_best.json")

MODES = ("限时挑战", "定量挑战", "生存挑战")


# ================= 与界面无关的纯逻辑 =================
class Stats:
    """收集一次训练的反应数据并给出统计结论。"""

    def __init__(self):
        self.reactions = []      # 每次命中的反应时间（秒）
        self.offsets = []        # (dx, dy) 点击位置相对圆心偏移（像素）
        self.misses = 0          # 点空 / 目标超时消失
        self.expired = 0         # 其中：超时消失数

    @property
    def hits(self):
        return len(self.reactions)

    @property
    def total_clicks(self):
        return self.hits + self.misses

    @property
    def accuracy(self):
        return self.hits / self.total_clicks * 100 if self.total_clicks else 0.0

    def summary(self):
        r = self.reactions
        n = len(r)
        if not n:
            return {"n": 0, "avg": 0, "best": 0, "worst": 0, "sd": 0,
                    "acc": 0, "rank": "—", "avg_offset": 0}
        avg = sum(r) / n
        best, worst = min(r), max(r)
        sd = math.sqrt(sum((x - avg) ** 2 for x in r) / n) if n > 1 else 0.0
        off = [math.hypot(dx, dy) for dx, dy in self.offsets]
        return {
            "n": n,
            "avg": avg,
            "best": best,
            "worst": worst,
            "sd": sd,
            "acc": self.hits / (self.hits + self.misses) * 100,
            "rank": rank_of(avg),
            "avg_offset": sum(off) / len(off) if off else 0.0,
        }


def rank_of(avg_sec):
    """按人类视觉反应基准给评级。"""
    ms = avg_sec * 1000
    if ms < 250:
        return "S · 职业级 ⚡"
    if ms < 320:
        return "A · 优秀"
    if ms < 400:
        return "B · 良好"
    if ms < 520:
        return "C · 合格"
    if ms < 700:
        return "D · 偏慢"
    return "E · 需要练习"


def score_for(reaction, radius, streak):
    """越快、目标越小分越高；连击额外加成。"""
    base = 100 * (1 - min(reaction, 1.2) / 1.2 * 0.65)          # 120ms→35分档位
    size_bonus = max(0.0, (34 - radius) / 34) * 40                # 小目标最高 +40
    combo = min(streak, 8) * 3                                    # 连击最高 +24
    return max(10, round(base + size_bonus + combo))


def random_pos(w, h, radius, margin=14):
    """在画布内随机取一个完整可见的圆点位置。"""
    r = radius + margin
    x = random.uniform(r, max(r + 1, w - r))
    y = random.uniform(r, max(r + 1, h - r))
    return x, y


def pick_cn_font(root):
    available = set(tkfont.families(root))
    for name in ("Microsoft YaHei", "Microsoft YaHei UI", "PingFang SC",
                 "Noto Sans CJK SC", "WenQuanYi Micro Hei", "Heiti SC", "SimHei"):
        if name in available:
            return name
    return "TkDefaultFont"


# ================= 主程序 =================
class AimTrainer(tk.Tk):
    def __init__(self):
        super().__init__()
        self.cn = pick_cn_font(self)
        self.title("目标练习器 · 反应速度测试")
        self.geometry("940x680")
        self.minsize(860, 620)
        self.configure(bg="#EEF2F8")

        # 设置项
        self.mode = tk.StringVar(value="限时挑战")
        self.duration = tk.IntVar(value=60)      # 限时模式秒数
        self.amount = tk.IntVar(value=30)        # 定量模式目标数
        self.radius = tk.IntVar(value=22)        # 目标半径
        self.lifetime = tk.IntVar(value=0)       # 目标存活秒数，0=不消失

        # 运行状态
        self.stats = Stats()
        self.score = 0
        self.streak = 0
        self.best_streak = 0
        self.target = None           # (x, y, r, born_at, canvas_id...)
        self.target_items = []
        self.start_at = 0.0
        self.time_limit = 0
        self.remain_targets = 0
        self.lives_left = 5
        self.paused = False
        self.running = False
        self._loop = None
        self.best = self._load_best()

        self._init_style()
        self._build_ui()
        self.show("setup")

    # ---------------- 基础 ----------------
    def _init_style(self):
        st = ttk.Style(self)
        try:
            st.theme_use("vista" if platform.system() == "Windows" else "clam")
        except tk.TclError:
            pass
        st.configure("TButton", font=(self.cn, 11), padding=6)
        st.configure("Go.TButton", font=(self.cn, 13, "bold"), padding=10)
        st.configure("Blue.Horizontal.TProgressbar", background="#3B7DDD")
        st.configure("Red.Horizontal.TProgressbar", background="#E5534B")
        st.configure("TProgressbar", thickness=16, troughcolor="#DDE4EF")

    def show(self, name):
        self.frames[name].tkraise()

    def _build_ui(self):
        wrap = tk.Frame(self, bg="#EEF2F8")
        wrap.pack(fill="both", expand=True)
        wrap.rowconfigure(0, weight=1)
        wrap.columnconfigure(0, weight=1)
        self.frames = {}
        for n in ("setup", "train", "result"):
            f = tk.Frame(wrap, bg="#EEF2F8")
            f.grid(row=0, column=0, sticky="nsew")
            self.frames[n] = f
        self._build_setup()
        self._build_train()
        self._build_result()

    # ---------------- 设置页 ----------------
    def _build_setup(self):
        f = self.frames["setup"]
        tk.Label(f, text="目标练习器", font=(self.cn, 27, "bold"),
                 bg="#EEF2F8", fg="#1B2438").pack(pady=(30, 4))
        tk.Label(f, text="随机圆点出现 → 用鼠标点它 → 测你的反应速度与精准度",
                 font=(self.cn, 11), bg="#EEF2F8", fg="#69748C").pack(pady=(0, 16))

        card = tk.Frame(f, bg="white")
        card.pack(padx=70, fill="both", expand=True)

        tk.Label(card, text="训练模式", font=(self.cn, 12, "bold"), bg="white",
                 fg="#1B2438").pack(anchor="w", padx=26, pady=(18, 6))
        mb = tk.Frame(card, bg="white")
        mb.pack(anchor="w", padx=22)
        for m in MODES:
            tk.Radiobutton(mb, text=m, value=m, variable=self.mode, font=(self.cn, 11),
                           bg="white", activebackground="white", selectcolor="#DCE8FF",
                           command=self._sync_mode).pack(side="left", padx=6)
        self.lb_mode_desc = tk.Label(card, text="", font=(self.cn, 10),
                                     bg="white", fg="#8A94A6")
        self.lb_mode_desc.pack(anchor="w", padx=28, pady=(2, 10))

        grid = tk.Frame(card, bg="white")
        grid.pack(fill="x", padx=26, pady=6)
        for i in range(4):
            grid.columnconfigure(i, weight=1)
        self._spin(grid, "时长（秒）", self.duration, 0, (15, 30, 60, 90, 120))
        self._spin(grid, "目标数量", self.amount, 1, (10, 20, 30, 50, 100))
        self._spin(grid, "圆点半径（px）", self.radius, 2, (12, 18, 22, 28, 36))
        self._spin(grid, "目标存活（秒,0=不消失）", self.lifetime, 3, (0, 1, 2, 3, 5))

        ttk.Button(card, text="开 始 训 练", style="Go.TButton",
                   command=self.start).pack(pady=(22, 6), ipadx=30)

        self.lb_best = tk.Label(card, text=self._best_text(), font=(self.cn, 10),
                                bg="white", fg="#8A94A6")
        self.lb_best.pack(pady=(4, 16))
        self._sync_mode()

    def _spin(self, parent, title, var, col, values):
        box = tk.Frame(parent, bg="white")
        box.grid(row=0, column=col, sticky="w", padx=6)
        tk.Label(box, text=title, font=(self.cn, 10), bg="white",
                 fg="#69748C").pack(anchor="w")
        ttk.Spinbox(box, from_=0, to=9999, width=12, values=values,
                    textvariable=var, font=(self.cn, 11)).pack(anchor="w", pady=4)

    def _sync_mode(self):
        desc = {
            "限时挑战": "在设定时间内尽可能多命中，越快分越高",
            "定量挑战": "打完指定数量的目标，比总用时与平均反应",
            "生存挑战": "目标会自行消失，漏掉 5 个就结束",
        }
        self.lb_mode_desc.config(text=desc[self.mode.get()])

    # ---------------- 训练页 ----------------
    def _build_train(self):
        f = self.frames["train"]
        bar = tk.Frame(f, bg="#EEF2F8")
        bar.pack(fill="x", padx=18, pady=(14, 6))
        self.lb_score = tk.Label(bar, text="0 分", font=(self.cn, 20, "bold"),
                                 bg="#EEF2F8", fg="#1B2438")
        self.lb_score.pack(side="left")
        self.lb_hud = tk.Label(bar, text="", font=(self.cn, 12), bg="#EEF2F8", fg="#69748C")
        self.lb_hud.pack(side="right")
        ttk.Button(bar, text="结束", command=lambda: self.finish(manual=True)).pack(side="right", padx=10)

        self.pb = ttk.Progressbar(f, mode="determinate", maximum=100, value=100)
        self.pb.pack(fill="x", padx=18, pady=(2, 8))

        self.canvas = tk.Canvas(f, bg="#F7F9FC", highlightthickness=1,
                                highlightbackground="#D8E0EE")
        self.canvas.pack(fill="both", expand=True, padx=18, pady=(0, 8))
        self.canvas.bind("<Button-1>", self.on_click)
        self.canvas.bind("<Configure>", lambda e: self._draw_grid())

        self.lb_tip = tk.Label(f, text="左键点击圆点 · Esc 结束本局",
                               font=(self.cn, 11), bg="#EEF2F8", fg="#8A94A6")
        self.lb_tip.pack(pady=(0, 12))
        self.bind("<Escape>", lambda e: self.finish(manual=True))

    def _draw_grid(self):
        c = self.canvas
        c.delete("grid")
        w, h = c.winfo_width(), c.winfo_height()
        if w < 10 or h < 10:
            return
        for x in range(0, w, 40):
            c.create_line(x, 0, x, h, fill="#EDF1F7", tags="grid")
        for y in range(0, h, 40):
            c.create_line(0, y, w, y, fill="#EDF1F7", tags="grid")

    # ---------------- 结算页 ----------------
    def _build_result(self):
        f = self.frames["result"]
        self.lb_r_title = tk.Label(f, text="训练结束", font=(self.cn, 24, "bold"),
                                   bg="#EEF2F8", fg="#1B2438")
        self.lb_r_title.pack(pady=(22, 4))
        self.lb_r_sub = tk.Label(f, text="", font=(self.cn, 14), bg="#EEF2F8", fg="#3B7DDD")
        self.lb_r_sub.pack()

        box = tk.Frame(f, bg="#EEF2F8")
        box.pack(fill="both", expand=True, padx=24, pady=10)
        box.columnconfigure(0, weight=3)
        box.columnconfigure(1, weight=2)

        left = tk.Frame(box, bg="white")
        left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
        self.lb_stats = tk.Label(left, text="", font=(self.cn, 13), bg="white",
                                 fg="#1B2438", justify="left")
        self.lb_stats.pack(padx=20, pady=16, anchor="w")

        right = tk.Frame(box, bg="white")
        right.grid(row=0, column=1, sticky="nsew")
        tk.Label(right, text="反应时间趋势（ms）", font=(self.cn, 11, "bold"),
                 bg="white", fg="#69748C").pack(pady=(10, 2))
        self.chart = tk.Canvas(right, bg="white", height=170, highlightthickness=0)
        self.chart.pack(fill="x", padx=12)
        tk.Label(right, text="点击偏移分布（精准度）", font=(self.cn, 11, "bold"),
                 bg="white", fg="#69748C").pack(pady=(8, 2))
        self.scatter = tk.Canvas(right, bg="white", height=150, highlightthickness=0)
        self.scatter.pack(fill="x", padx=12, pady=(0, 10))

        btns = tk.Frame(f, bg="#EEF2F8")
        btns.pack(pady=(4, 16))
        ttk.Button(btns, text="再来一局", command=self.start).pack(side="left", padx=6)
        ttk.Button(btns, text="返回设置", command=lambda: self.show("setup")).pack(side="left", padx=6)

    # ---------------- 开始 / 结束 ----------------
    def start(self):
        self.stats = Stats()
        self.score = 0
        self.streak = 0
        self.best_streak = 0
        self.lives_left = 5
        self.paused = False
        self.running = True
        self.target = None
        self.start_at = time.time()
        mode = self.mode.get()
        if mode == "限时挑战":
            self.time_limit = max(5, self.duration.get())
            self.remain_targets = 0
        elif mode == "定量挑战":
            self.time_limit = 0
            self.remain_targets = max(1, self.amount.get())
        else:
            self.time_limit = 0
            self.remain_targets = 0
        self.show("train")
        self.canvas.config(cursor="crosshair")
        self._draw_grid()
        self.after(60, self.spawn)
        self._tick()
        self._update_hud()

    def finish(self, manual=False, reason=""):
        if not self.running:
            return
        self.running = False
        if self._loop:
            self.after_cancel(self._loop)
            self._loop = None
        self._clear_target()
        self.canvas.config(cursor="")
        self.canvas.delete("fx")

        s = self.stats.summary()
        used = time.time() - self.start_at
        if not s["n"] and manual:
            self.show("setup")
            return

        self.lb_r_title.config(text="本局结束" if not reason else reason)
        self.lb_r_sub.config(text=f"得分 {self.score}　评级 {s['rank']}")
        self.lb_stats.config(text=(
            f"命中 / 点击　{s['n']} / {s['n'] + self.stats.misses}\n"
            f"命中率　　　　{s['acc']:.1f}%\n"
            f"平均反应　　　{s['avg'] * 1000:.0f} ms\n"
            f"最快 / 最慢　 {s['best'] * 1000:.0f} / {s['worst'] * 1000:.0f} ms\n"
            f"稳定性（σ）　 {s['sd'] * 1000:.0f} ms\n"
            f"平均偏移　　　{s['avg_offset']:.1f} px\n"
            f"最高连击　　　{self.best_streak}\n"
            f"总用时　　　　{used:.1f} s"
        ) if s["n"] else "本局没有命中任何目标，再来一次吧。")
        self._draw_charts()
        self._save_best(s)
        self.show("result")

    # ---------------- 目标生成与命中 ----------------
    def spawn(self, event=None):
        if not self.running:
            return
        self._clear_target()
        c = self.canvas
        w, h = c.winfo_width(), c.winfo_height()
        r = max(8, self.radius.get())
        if w < 60 or h < 60:
            self.after(100, self.spawn)
            return
        x, y = random_pos(w, h, r)
        life = max(0, self.lifetime.get())
        if self.mode.get() == "生存挑战" and life <= 0:
            life = 2.0                       # 生存模式默认给 2 秒
        self.target = {"x": x, "y": y, "r": r, "born": time.time(), "life": life}
        # 弹出动画
        for i, (k, color) in enumerate(((0.55, "#B9D3FF"), (0.8, "#7FAEF5"), (1.0, "#3B7DDD"))):
            rr = r * k
            self.target_items.append(c.create_oval(
                x - rr, y - rr, x + rr, y + rr, fill=color, outline="", tags="target"))
        self.target_items.append(c.create_oval(
            x - r * 0.28, y - r * 0.28, x + r * 0.28, y + r * 0.28,
            fill="white", outline="", tags="target_center"))

    def _clear_target(self):
        self.canvas.delete("target")
        self.canvas.delete("target_center")
        self.target_items.clear()
        self.target = None

    def on_click(self, event):
        if not self.running or not self.target:
            return
        t = self.target
        dx, dy = event.x - t["x"], event.y - t["y"]
        if dx * dx + dy * dy <= t["r"] * t["r"]:
            rt = time.time() - t["born"]
            self.stats.reactions.append(rt)
            self.stats.offsets.append((dx, dy))
            self.streak += 1
            self.best_streak = max(self.best_streak, self.streak)
            self.score += score_for(rt, t["r"], self.streak - 1)
            self._pop_hit(t["x"], t["y"], t["r"], ok=True, rt=rt)
            self._clear_target()
            if self.mode.get() == "定量挑战":
                self.remain_targets -= 1
            self.after(40, self.spawn)
        else:
            self.stats.misses += 1
            self.streak = 0
            self._pop_hit(event.x, event.y, 10, ok=False)
        self._update_hud()

    def _pop_hit(self, x, y, r, ok, rt=None):
        c = self.canvas
        color = "#2FA84F" if ok else "#E5534B"
        c.create_oval(x - r, y - r, x + r, y + r, outline=color, width=3, tags="fx")
        txt = f"{rt * 1000:.0f} ms" if ok else "Miss"
        c.create_text(x, y - r - 12, text=txt, fill=color,
                      font=(self.cn, 12, "bold"), tags="fx")
        self.after(280, lambda: c.delete("fx"))

    def _miss_target(self):
        """目标存活超时消失。"""
        self.stats.misses += 1
        self.stats.expired += 1
        self.streak = 0
        c = self.canvas
        if self.target:
            c.create_oval(self.target["x"] - self.target["r"], self.target["y"] - self.target["r"],
                          self.target["x"] + self.target["r"], self.target["y"] + self.target["r"],
                          outline="#E5534B", width=2, dash=(4, 3), tags="fx")
            self.after(300, lambda: c.delete("fx"))
        self._clear_target()
        self.lives_left -= 1
        self._update_hud()
        if self.lives_left <= 0:
            self.finish(reason="目标漏太多了！")
            return
        self.after(60, self.spawn)

    # ---------------- 循环 ----------------
    def _tick(self):
        if not self.running:
            return
        if self.time_limit > 0:
            left = max(0.0, self.time_limit - (time.time() - self.start_at))
            self.pb["value"] = left / self.time_limit * 100
            self.pb.configure(style="Red.Horizontal.TProgressbar" if left <= 8
                               else "Blue.Horizontal.TProgressbar")
            if left <= 0:
                self.finish(reason="时间到！")
                return
        elif self.remain_targets > 0:
            done = max(1, self.amount.get())
            self.pb["value"] = (done - self.remain_targets) / done * 100
            self.pb.configure(style="Blue.Horizontal.TProgressbar")
            if self.remain_targets <= 0:
                self.finish(reason="全部完成！")
                return
        else:
            self.pb["value"] = 100
        # 目标存活
        t = self.target
        if t and t["life"] > 0 and time.time() - t["born"] > t["life"]:
            self._miss_target()
            if not self.running:
                return
        self._loop = self.after(30, self._tick)

    def _update_hud(self):
        s = self.stats.summary()
        extra = ""
        if self.mode.get() == "限时挑战" and self.time_limit:
            extra = f"剩余 {max(0.0, self.time_limit - (time.time() - self.start_at)):.1f}s　"
        elif self.mode.get() == "定量挑战":
            extra = f"剩余目标 {self.remain_targets}　"
        else:
            extra = f"剩余机会 {self.lives_left}　"
        last = f"最近 {self.stats.reactions[-1] * 1000:.0f} ms" if self.stats.reactions else "最近 —"
        self.lb_score.config(text=f"{self.score} 分")
        self.lb_hud.config(text=f"{extra}命中 {s['n']}　失误 {self.stats.misses}　"
                                f"命中率 {s['acc']:.0f}%　连击 {self.streak}　{last}")

    # ---------------- 图表 ----------------
    def _draw_charts(self):
        self.chart.delete("all")
        self.scatter.delete("all")
        self.after(200, self._render_charts)   # 等控件布局完成拿到真实宽高

    def _render_charts(self):
        data = [r * 1000 for r in self.stats.reactions]
        self._line_chart(self.chart, data)
        self._scatter_chart(self.scatter, self.stats.offsets)

    def _line_chart(self, c, data):
        c.delete("all")
        w, h = c.winfo_width(), c.winfo_height()
        if w < 20 or h < 20 or not data:
            return
        pad, top, bot = 34, 16, 22
        vmax = max(max(data), 600) * 1.1
        plot_h = h - top - bot
        n = len(data)
        def xy(i, v):
            x = pad + (w - pad - 12) * (i / max(1, n - 1) if n > 1 else 0.5)
            y = top + plot_h * (1 - v / vmax)
            return x, y
        # 网格与刻度
        for frac in (0, 0.25, 0.5, 0.75, 1.0):
            y = top + plot_h * (1 - frac)
            c.create_line(pad, y, w - 12, y, fill="#EEF2F8")
            c.create_text(pad - 4, y, text=f"{vmax * frac:.0f}", anchor="e",
                          fill="#98A3B8", font=(self.cn, 8))
        # 平均线
        avg = sum(data) / n
        _, ay = xy(0, avg)
        c.create_line(pad, ay, w - 12, ay, fill="#F0A020", dash=(4, 3))
        c.create_text(w - 14, ay - 9, text=f"均值 {avg:.0f}", anchor="e",
                      fill="#F0A020", font=(self.cn, 9))
        # 折线
        pts = [xy(i, v) for i, v in enumerate(data)]
        if n > 1:
            c.create_line(pts, fill="#3B7DDD", width=2, smooth=False)
        for (x, y), v in zip(pts, data):
            color = "#2FA84F" if v <= 320 else ("#F0A020" if v <= 500 else "#E5534B")
            c.create_oval(x - 2.5, y - 2.5, x + 2.5, y + 2.5, fill=color, outline="")
        c.create_text(w // 2, h - 8, text=f"共 {n} 次命中", fill="#98A3B8",
                      font=(self.cn, 9))

    def _scatter_chart(self, c, offsets):
        c.delete("all")
        w, h = c.winfo_width(), c.winfo_height()
        if w < 20 or h < 20:
            return
        cx, cy = w / 2, h / 2
        R = min(w, h) / 2 - 16
        c.create_oval(cx - R, cy - R, cx + R, cy + R, outline="#E3E9F3")
        scale = R / max(30.0, self.radius.get())
        for r_frac in (0.33, 0.66, 1.0):
            rr = R * r_frac
            c.create_oval(cx - rr, cy - rr, cx + rr, cy + rr,
                          outline="#F0F3F9", dash=(2, 3))
        c.create_line(cx - R, cy, cx + R, cy, fill="#F0F3F9")
        c.create_line(cx, cy - R, cx, cy + R, fill="#F0F3F9")
        if not offsets:
            c.create_text(cx, cy, text="无数据", fill="#B6BECD", font=(self.cn, 11))
            return
        inside = 0
        for dx, dy in offsets:
            px, py = cx + dx * scale, cy + dy * scale
            d = math.hypot(dx, dy)
            ok = d <= self.radius.get()
            inside += ok
            c.create_oval(px - 2.2, py - 2.2, px + 2.2, py + 2.2,
                          fill="#3B7DDD" if ok else "#E5534B", outline="")
        c.create_text(6, 8, text=f"圈内命中 {inside}/{len(offsets)}", anchor="nw",
                      fill="#98A3B8", font=(self.cn, 9))

    # ---------------- 记录 ----------------
    def _best_text(self):
        b = self.best.get("best")
        if not b:
            return "还没有记录，来刷第一局吧"
        return (f"最佳：平均反应 {b['avg'] * 1000:.0f} ms　"
                f"最高分 {b['score']}　命中率 {b['acc']:.0f}%")

    def _save_best(self, s):
        if not s["n"]:
            return
        cur = {"avg": s["avg"], "score": self.score, "acc": s["acc"]}
        prev = self.best.get("best")
        if not prev or cur["score"] > prev["score"]:
            self.best["best"] = cur
            try:
                with open(SAVE_FILE, "w", encoding="utf-8") as fp:
                    json.dump(self.best, fp, ensure_ascii=False)
            except Exception:
                pass
            self.lb_best.config(text=self._best_text())

    def _load_best(self):
        try:
            with open(SAVE_FILE, "r", encoding="utf-8") as fp:
                return json.load(fp)
        except Exception:
            return {}


if __name__ == "__main__":
    AimTrainer().mainloop()
