using System; using System.Collections.Generic; using System.Linq; using System.Text; using UnityEngine; public class Solver { // -------- Параметры/пресет -------- private static int ROWS = 6; private static int COLS = 7; private static int[] MoveOrder = BuildMoveOrder(COLS); private static int[] CenterWeights = BuildCenterWeights(COLS); private static System.Random rand = new System.Random(); // текущие фигуры бота и оппонента — задаём при вызове FindBestMove private static Team BotPiece; private static Team HumanPiece; private static int DEPTH = 8; private static int WIN_SCORE = 1_000_000; private static int TWO_IN_ROW = 100; private static int THREE_IN_ROW = 500; private static int CENTER_COLUMN_BONUS = 20; private static int BLOCK_OPPONENT_BONUS = 5_000; private static float RANDOM_MOVE_RATE = 0f; private static float MISTAKE_RATE = 0f; private static float TEMPERATURE = 0f; private static float SCORE_NOISE_SIGMA = 0f; private static int TOP_K = 1; private static float AVOID_LOSS_BAN_PROB = 1.0f; // 0..1 — с какой вероятностью мы ВООБЩЕ отсекаем ходы, ведущие к немедленному проигрышу private static int IMMEDIATE_LOSS_PENALTY = 10000; // штраф, если такой ход всё же в корзине (мягко отталкивает) public static void SetPreset(DifficultyPreset preset) { //Debug.Log(preset.name); if (preset == null) return; DEPTH = preset.Depth; WIN_SCORE = preset.WinBonus; TWO_IN_ROW = preset.RowOfTwoBonus; THREE_IN_ROW = preset.RowOfThree; CENTER_COLUMN_BONUS = preset.CenterBonus; BLOCK_OPPONENT_BONUS = preset.BlockBonus; RANDOM_MOVE_RATE = preset.RandomMoveRate; MISTAKE_RATE = preset.MistakeRate; TEMPERATURE = preset.Temperature; SCORE_NOISE_SIGMA = preset.ScoreNoiseSigma; TOP_K = Mathf.Max(1, preset.TopK); AVOID_LOSS_BAN_PROB = Mathf.Clamp01(preset.AvoidLossBanProb); IMMEDIATE_LOSS_PENALTY = preset.IMMEDIATE_LOSS_PENALTY; //Debug.Log($"[Solver] Preset: {preset.Description}"); } // -------- Публичный API -------- public static int FindBestMove(Team[,] board, Team currentPlayer) { if (board == null) throw new ArgumentNullException(nameof(board)); ROWS = board.GetLength(0); COLS = board.GetLength(1); MoveOrder = BuildMoveOrder(COLS); CenterWeights = BuildCenterWeights(COLS); // сохраняем, за кого сейчас считаем BotPiece = currentPlayer; HumanPiece = (currentPlayer == Team.Player1) ? Team.Player2 : Team.Player1; if (IsBoardEmpty(board)) return rand.NextDouble() < RANDOM_MOVE_RATE ? rand.Next(COLS) : COLS / 2; int immediateWinningMove = FindImmediateWinningMove(board, BotPiece); if (immediateWinningMove >= 0) return immediateWinningMove; int searchDepth = GetSearchDepth(); var root = EvaluateRootMoves(board, searchDepth); if (root.Count == 0) return -1; // дальше код остаётся тот же if (AVOID_LOSS_BAN_PROB > 0f) root.RemoveAll(m => LeadsToImmediateLoss(board, m.col) && rand.NextDouble() < AVOID_LOSS_BAN_PROB); if (root.Count == 0) root = EvaluateRootMoves(board, Math.Max(1, searchDepth - 1)); if (root.Count == 0) return -1; if (rand.NextDouble() < RANDOM_MOVE_RATE) return root[rand.Next(root.Count)].col; List<(int col, double score)> noisy = new(root.Count); foreach (var (col, score) in root) { double val = score; if (IMMEDIATE_LOSS_PENALTY > 0 && LeadsToImmediateLoss(board, col)) val -= IMMEDIATE_LOSS_PENALTY; if (SCORE_NOISE_SIGMA > 0f) val += NextGaussian(0, SCORE_NOISE_SIGMA); noisy.Add((col, val)); } noisy.Sort((a, b) => b.score.CompareTo(a.score)); var candidates = noisy.Take(Mathf.Min(TOP_K, noisy.Count)).ToList(); if (rand.NextDouble() < MISTAKE_RATE && candidates.Count > 1) for (int i = 1; i < candidates.Count; i++) if (!(AVOID_LOSS_BAN_PROB >= 1f && LeadsToImmediateLoss(board, candidates[i].col))) return candidates[i].col; if (TEMPERATURE > 0f && candidates.Count > 1) return SampleSoftmax(candidates, TEMPERATURE); return candidates[0].col; } private static int[] BuildMoveOrder(int columns) { var order = new List(columns); int center = columns / 2; order.Add(center); for (int offset = 1; order.Count < columns; offset++) { int right = center + offset; int left = center - offset; if (right < columns) order.Add(right); if (left >= 0) order.Add(left); } return order.ToArray(); } private static int[] BuildCenterWeights(int columns) { var weights = new int[columns]; for (int column = 0; column < columns; column++) { int doubledDistance = Math.Abs(column * 2 - (columns - 1)); weights[column] = Math.Max(1, columns - (doubledDistance + 1) / 2); } return weights; } private static bool IsBoardEmpty(Team[,] board) { for (int row = 0; row < ROWS; row++) for (int column = 0; column < COLS; column++) if (board[row, column] != Team.None) return false; return true; } private static int GetSearchDepth() { if (COLS >= 8) return Math.Min(DEPTH, 6); if (COLS >= 7 && ROWS >= 7) return Math.Min(DEPTH, 7); return DEPTH; } private static int FindImmediateWinningMove(Team[,] board, Team team) { foreach (int column in MoveOrder) { int row = GetNextOpenRow(board, column); if (row < 0) continue; board[row, column] = team; bool isWinningMove = FieldChecker.IsWinningMove(board, row, column, team); board[row, column] = Team.None; if (isWinningMove) return column; } return -1; } // -------- Корневые ходы -------- private static List<(int col, int score)> EvaluateRootMoves(Team[,] board, int depth) { var result = new List<(int col, int score)>(); var valid = GetValidLocations(board); if (valid.Count == 0) return result; foreach (int col in MoveOrder) { if (!valid.Contains(col)) continue; int row = GetNextOpenRow(board, col); if (row < 0) continue; // применяем ход бота board[row, col] = BotPiece; // если сразу победа — отлично if (FieldChecker.IsWinningMove(board, row, col, BotPiece)) { board[row, col] = Team.None; result.Add((col, WIN_SCORE * depth)); continue; } int score = Minimax(board, depth - 1, int.MinValue, int.MaxValue, false, row, col, BotPiece).score; board[row, col] = Team.None; result.Add((col, score)); } return result; } private static bool LeadsToImmediateLoss(Team[,] board, int botCol) { int row = GetNextOpenRow(board, botCol); if (row < 0) return false; // бот сделал ход board[row, botCol] = BotPiece; // если этот ход уже выигрывает нам игру — не считаем его "ведущим к проигрышу", // соперник просто не получит ход if (FieldChecker.IsWinningMove(board, row, botCol, BotPiece)) { board[row, botCol] = Team.None; return false; } // может ли оппонент выиграть в 1 ход? bool res = FieldChecker.HasImmediateWin(board, HumanPiece); // откат board[row, botCol] = Team.None; return res; } /*private static bool LeadsToImmediateLoss(CellState[,] board, int botCol) { int row = GetNextOpenRow(board, botCol); if (row < 0) return false; // бот сделал ход board[row, botCol] = BOT; // СНАЧАЛА проверяем, не выигрывает ли сам бот этим ходом bool botWins = WinningMoveAt(board, row, botCol, BOT); // может ли человек выиграть в 1 ход? bool opponentWins = HasImmediateWin(board, HUMAN); // откат board[row, botCol] = CellState.Empty; // Если бот выигрывает - это не проигрышная позиция // Если бот не выигрывает, но противник может - это проигрышная позиция return !botWins && opponentWins; }*/ // -------- Минимакс -------- // maximizingPlayer == true → ход бота (BOT) // maximizingPlayer == false → ход человека (HUMAN) private static (int column, int score) Minimax( Team[,] board, int depth, int alpha, int beta, bool maximizingPlayer, int lastRow, int lastCol, Team lastPlayer) { if (lastRow >= 0 && FieldChecker.IsWinningMove(board, lastRow, lastCol, lastPlayer)) { int s = (lastPlayer == BotPiece ? +WIN_SCORE : -WIN_SCORE) * Math.Max(1, depth); return (-1, s); } var valid = GetValidLocations(board); if (valid.Count == 0 || depth == 0) return (-1, EvaluatePosition(board, BotPiece)); if (maximizingPlayer) { int best = int.MinValue; int bestCol = valid[0]; foreach (int col in MoveOrder) { if (!valid.Contains(col)) continue; int r = GetNextOpenRow(board, col); if (r < 0) continue; board[r, col] = BotPiece; var child = Minimax(board, depth - 1, alpha, beta, false, r, col, BotPiece); board[r, col] = Team.None; if (child.score > best) { best = child.score; bestCol = col; } alpha = Math.Max(alpha, best); if (alpha >= beta) break; } return (bestCol, best); } else { int best = int.MaxValue; int bestCol = valid[0]; foreach (int col in MoveOrder) { if (!valid.Contains(col)) continue; int r = GetNextOpenRow(board, col); if (r < 0) continue; board[r, col] = HumanPiece; var child = Minimax(board, depth - 1, alpha, beta, true, r, col, HumanPiece); board[r, col] = Team.None; if (child.score < best) { best = child.score; bestCol = col; } beta = Math.Min(beta, best); if (alpha >= beta) break; } return (bestCol, best); } } // -------- Эвристика -------- private static int EvaluatePosition(Team[,] board, Team perspective /* всегда BOTpiece */) { int score = 0; var opp = (perspective == Team.Player1) ? Team.Player2 : Team.Player1; // 1) Центр (весим 3-4-5-7-5-4-3), не масштабируем на DEPTH for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) if (board[r, c] == perspective) score += CENTER_COLUMN_BONUS * CenterWeights[c]; // 2) Потенциалы (гравитация учитывается) score += CountPotentialLinesPlayable(board, perspective, 3) * THREE_IN_ROW; score += CountPotentialLinesPlayable(board, perspective, 2) * TWO_IN_ROW; // 3) Наказание за потенциалы оппонента score -= CountPotentialLinesPlayable(board, opp, 3) * THREE_IN_ROW * 2; score -= CountPotentialLinesPlayable(board, opp, 2) * TWO_IN_ROW; // 4) Немедленная угроза оппонента → сильный штраф (чтобы бот приоритизировал блок) if (FieldChecker.HasImmediateWin(board, opp)) score -= BLOCK_OPPONENT_BONUS; return score; } // Счёт «окон» длины 4, где pieceCount==length и остальные пусты, причём // хотя бы одна из пустых клеток «играбельна» (подперта снизу или нижняя граница). private static int CountPotentialLinesPlayable(Team[,] b, Team piece, int length) { int total = 0; bool In(int r, int c) => r >= 0 && r < ROWS && c >= 0 && c < COLS; bool PlayableEmpty(int r, int c) => r == ROWS - 1 || b[r + 1, c] != Team.None; int ScanDir(int dr, int dc) { int cnt = 0; // стартовые точки так, чтобы окно из 4 влазило for (int r0 = 0; r0 < ROWS; r0++) { for (int c0 = 0; c0 < COLS; c0++) { int r1 = r0 + dr * 3, c1 = c0 + dc * 3; if (!In(r1, c1)) continue; int p = 0, e = 0; bool hasPlayableEmpty = false; bool blocked = false; for (int k = 0; k < 4; k++) { int rr = r0 + dr * k, cc = c0 + dc * k; var v = b[rr, cc]; if (v == piece) p++; else if (v == Team.None) { e++; if (PlayableEmpty(rr, cc)) hasPlayableEmpty = true; } else // оппонент { blocked = true; break; } } if (!blocked && p == length && e == 4 - length && (length == 4 || hasPlayableEmpty)) cnt++; } } return cnt; } total += ScanDir(0, +1); // горизонтали total += ScanDir(+1, 0); // вертикали total += ScanDir(+1, +1); // диагональ "\" total += ScanDir(-1, +1); // диагональ "/" return total; } // -------- Хелперы по ходам/победам -------- private static List GetValidLocations(Team[,] board) { var res = new List(COLS); for (int c = 0; c < COLS; c++) if (board[0, c] == Team.None) res.Add(c); return res; } private static int GetNextOpenRow(Team[,] board, int col) { for (int r = ROWS - 1; r >= 0; r--) if (board[r, col] == Team.None) return r; return -1; } // -------- Стохастика на корне -------- /// /// При околонулевой температуре с великой вероятностью лучший по score /// При температуре 1 вероятность по соотношению очков /// При более высоких температурах вероятности равномерно разглаживаются /// private static int SampleSoftmax(List<(int col, double score)> items, float temperature) { //StringBuilder sb = new(); //находим максимум оценок double max = items.Max(x => x.score); //попытка нормализовать оценки //Готовим накопители double sum = 0; double[] e = new double[items.Count]; //sb.Append("[Экспоненты]\n"); //Перебираем items (Считает экспоненты “нормализованных” логитов) for (int i = 0; i < items.Count; i++) { //из кандидата вычитаем max, делим разность на температуру double v = (items[i].score - max) / Math.Max(1e-6, (double)temperature); //берем экспоненту от результата double ev = Math.Exp(v); //заносим преобразованные данне в накопители //sb.Append($"{items[i]} -> {ev}\n"); e[i] = ev;//счета, возведенные в экспоненты sum += ev; } //Debug.Log(sb.ToString()); //жеребьевка по накопленным весам double r = rand.NextDouble() * sum, acc = 0;//сюда суммируются экспоненты очков (сумма весов) for (int i = 0; i < items.Count; i++) { acc += e[i]; if (r <= acc) { //Debug.Log($"Выбранный вариант {items[i].col}"); return items[i].col;//выбор по ленте } } return items[0].col; //на всякий случай возвращаем лучший } private static double NextGaussian(double mu, double sigma) { double u1 = 1.0 - rand.NextDouble(); double u2 = 1.0 - rand.NextDouble(); double z0 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2); return mu + sigma * z0; } }