Сьогодні писатимемо гру «Хрестики-Нолики» із використанням сервлетів та JSP.

Цей проєкт трохи відрізнятиметься від попередніх. У ньому будуть не лише завдання, а й пояснення, як їх зробити. Тобто це буде проєкт із серії «HOW TO…».

Інструкція:

  1. Зробити fork із репозиторію: https://github.com/vasylmalik/project-servlet.git
  2. Завантажити свою версію проєкту на комп'ютер.
  3. Налаштувати запуск програми в IDEA:
    • Alt + Shift + F9 -> Edit Configurations ... -> Alt + insert -> tom (у рядок пошуку) -> Local.
    • Після цього потрібно натиснути “CONFIGURE” та вказати, куди завантажено і розпаковано архів із Tomcat.
    • У вкладці "Deployment": Alt + insert -> Artifact ... -> tic-tac-toe: war exploded -> OK.
    • У полі “Application context”: залишити лише “/” (слеш).
    • Натиснути “APPLY”.
    • Закрити вікно налаштування.
    • Зробити перший пробний запуск налаштованої конфігурації. Якщо все зробити правильно – відкриється твій браузер за замовчуванням, у якому буде:
  4. Відкрий файл “pom.xml”. У блоці “dependencies” є дві залежності.
    • javax.servlet-api – відповідає за специфікацію сервлетів. Scope "provided" потрібен під час розробки, але не потрібен у рантаймі (у Tomcat вже є ця залежність у папці lib).
    • jstl – можна розглядати як шаблонизатор.
  5. У папці "webapp" є 3 файли:
    • index.jsp – це наш шаблон (аналог HTML-сторінки). У ньому буде розмітка та скрипти. Саме файл із назвою “index” віддається як початкова сторінка, якщо немає будь-яких конфігурацій, як ми побачили в п.3.
    • /static/main.css – файл для стилів. Як і в минулому проєкті, тут все на твій розсуд, розфарбовуй як хочеш.
    • /static/jquery-3.6.0.min.js – фронтенд залежність, яку наш сервер роздаватиме як статику.
  6. У пакеті com.tictactoe буде весь Java-код. Наразі там є 2 класи:
    • Sign – enum, який відповідає за “хрестик/нуль/порожнеча”.
    • Field – це наше поле. У цьому класі є карта "field". Принцип зберігання даних буде таким: осередки поля хрестиків-нуліків пронумеровані з нуля. У першому рядку 0, 1 та 2. У другому: 3, 4 та 5, тощо. Є також 3 методи. "getEmptyFieldIndex" шукає перший незайнятий осередок (так, суперник у нас буде не дуже розумним). "checkWin" перевіряє, чи не завершена гра. Якщо є ряд із трьох хрестиків – повертає хрестик, якщо із трьох нуликів – нулик. Інакше – порожнечу. "getFieldData" – повертає значення карти "field" як список, відсортований за зростанням індексів.
  7. Пояснення щодо темпліту завершено: тепер можна братися за виконання завдання. Почнемо з того, що намалюємо таблицю 3 на 3. Для цього до “index.jsp” додай код:
    
    <table> 
    	<tr> 
    		<td>0</td> 
    		<td>1</td> 
    		<td>2</td> 
    	</tr> 
    	<tr> 
    		<td>3</td> 
    		<td>4</td> 
    		<td>5</td> 
    	</tr> 
    	<tr> 
    		<td>6</td> 
    		<td>7</td> 
    		<td>8</td> 
    	</tr> 
    </table> 	
    				
    Числа в таблиці потім приберемо і замінимо їх на хрестик, нулик чи порожнє поле. Також усередині тега "head" підключи файл стилів. Для цього додай рядок:<link href="static/main.css" rel="stylesheet">

    Вміст файлу стилів залишається на твій розсуд. Я використав такий:
    
    td { 
        border: 3px solid black; 
        padding: 10px; 
        border-collapse: separate; 
        margin: 10px; 
        width: 100px; 
        height: 100px; 
        font-size: 50px; 
        text-align: center; 
        empty-cells: show; 
    } 
    		
    Після запуску у мене результат виглядає так:
  8. Тепер давай додамо такий функціонал: після кліку на ячейку на сервер буде надсилатися запит, в якому параметром передамо індекс ячейки, на яку ми клікнули. Це завдання можна поділити на дві частини: з фронту надіслати запит, на сервер запит прийняти. Давай, задля різноманітності, почнемо з фронту.

    Кожному тегу “d” додамо параметр “onclick”. У значенні вкажемо зміну поточної сторінки на вказану URL-адресу. Сервлет, який відповідатиме за логіку, матиме URL “/logic” і буде приймати параметр під назвою “click”. Так будемо передавати індекс ячейки, на яку клікнув користувач.
    
    <table> 
        <tr> 
            <td onclick="window.location='/logic?click=0'">0</td> 
            <td onclick="window.location='/logic?click=1'">1</td> 
            <td onclick="window.location='/logic?click=2'">2</td> 
        </tr> 
        <tr> 
            <td onclick="window.location='/logic?click=3'">3</td> 
            <td onclick="window.location='/logic?click=4'">4</td> 
            <td onclick="window.location='/logic?click=5'">5</td> 
        </tr> 
        <tr> 
            <td onclick="window.location='/logic?click=6'">6</td> 
            <td onclick="window.location='/logic?click=7'">7</td> 
            <td onclick="window.location='/logic?click=8'">8</td> 
        </tr> 
    </table> 			
    
    		
    Перевірити, що все зроблено правильно, можна через панель розробника в браузері. Наприклад, у Chrome вона відкривається кнопкою F12. В результаті кліка на ячейку з індексом 4 картина буде така: Помилки ми отримуємо тому, що сервлет, який може віддати сервер за адресою "logic", ми ще не створили.
  9. У пакеті "com.tictactoe" створи клас "LogicServlet", який потрібно успадкувати від класу "javax.servlet.http.HttpServlet". У класі перевизнач метод “doGet”.

    І давай додамо метод, який отримуватиме індекс ячейки, на яку клікнули. Також потрібно додати мапінг (адресу, за якою цей сервлет перехоплюватиме запит). Пропоную це робити через анотацію (але якщо любиш проблеми – можна і через web.xml). Загальний код сервлету:
    
    package com.tictactoe;
    
    import javax.servlet.ServletException;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    
    @WebServlet(name = "LogicServlet", value = "/logic")
    public class LogicServlet extends HttpServlet {
        @Override
    	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            int index = getSelectedIndex(req);
            resp.sendRedirect("/index.jsp");
        }
    
    
        private int getSelectedIndex(HttpServletRequest request) {
            String click = request.getParameter("click");
            boolean isNumeric = click.chars().allMatch(Character::isDigit);
            return isNumeric ? Integer.parseInt(click) : 0;
        }
    
    }
    		
    Тепер, під час кліку на будь-яку ячейку ми будемо отримувати на сервері індекс цієї ячейки (можна переконатися, запустивши сервер у режимі дебагу). Відбуватиметься редирект на цю ж сторінку, з якої було зроблено клік.
  10. 
    package com.tictactoe;
    
    import javax.servlet.ServletException;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    import java.io.IOException;
    import java.util.List;
    import java.util.Map;
    
    @WebServlet(name = "InitServlet", value = "/start")
    public class InitServlet extends HttpServlet {
        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            // Створення нової сесії
            HttpSession currentSession = req.getSession(true);
    
            // Створення ігрового поля поля
            Field field = new Field();
            Map<Integer, Sign> fieldData = field.getField();
    
            // Отримання списку значень поля
            List<Sign> data = field.getFieldData();
    
            // Додавання до сесії параметрів поля (буде потрібно для зберігання стану між запитами)
            currentSession.setAttribute("field", field);
            // і значень поля, відсортованих за індексом (потрібно для промалювання хрестиків і нуликів)
            currentSession.setAttribute("data", data);
    
            // Перенаправлення запиту на сторінку index.jsp через сервер
            getServletContext().getRequestDispatcher("/index.jsp").forward(req, resp);
        }
    }
    
    І щоб не забути, давай стартову сторінку, яка відкривається в браузері після запуску сервера, змінимо на “/start”: Тепер після перезапуску сервера та кліку на будь-яку ячейку поля в меню розробника браузера в секції “Request Headers” буде cookies з ідентифікатором сесії:
  11. Коли в нас з'явилося сховище, в якому ми можемо зберігати стан між запитами клієнта (браузера), можна починати писати логіку гри. Логіка у нас знаходиться в “LogicServlet”. Працювати потрібно з методом “doGet”. Давай додамо до методу таку поведінку:
    • отримаємо об'єкт "field" типу Field із сесії (винесемо в метод "extractField").
    • поставимо хрестик там, де клікнув користувач (поки що без будь-яких перевірок).
    
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Отримуємо поточну сесію
        HttpSession currentSession = req.getSession();
    
        // Отримуємо об'єкт ігрового поля з сесії
        Field field = extractField(currentSession);
    
        // Отримуємо індекс ячейки, на яку відбувся клік
        int index = getSelectedIndex(req);
    
        // Ставимо хрестик в ячейці, на яку клікнув користувач
        field.getField().put(index, Sign.CROSS);
    
        // Рахуємо список значків
        List<Sign> data = field.getFieldData();
    
        // Оновлюємо об'єкт поля і список значків у сесії
        currentSession.setAttribute("data", data);
        currentSession.setAttribute("field", field);
    
        resp.sendRedirect("/index.jsp");
    }
    
    
    
    private Field extractField(HttpSession currentSession) {
        Object fieldAttribute = currentSession.getAttribute("field");
        if (Field.class != fieldAttribute.getClass()) {
            currentSession.invalidate();
            throw new RuntimeException("Session is broken, try one more time");
        }
        return (Field) fieldAttribute;
    }
    
    Поведінка поки не змінилася, але якщо запустити сервер у дебаг режимі та поставити брейкпоінт на рядку, звідки надсилається редирект, можна подивитися "нутрощі" об'єкта "data". Там справді з'являється “CROSS” під індексом, на який був клік.
  12. Тепер час відобразити хрестик на фронтенді. Для цього попрацюємо з файлом "index.jsp" та технологією "JSTL".
    • У секції <head> додамо:<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    • У таблиці всередині кожного блоку <td> поміняємо індекс на конструкцію, що дозволяє обчислювати значення. Наприклад, для індексу нуль: <td onclick="window.location='/logic?click=0'">${data.get(0).getSign()}</td> Тепер під час кліку на ячейку там з'являтиметься хрестик:
  13. Ми свій хід зробили, тепер черга за “нуликом”. Додамо кілька перевірок тут же, щоб знаки не ставилися до вже зайнятих клітин.
    • Потрібно перевірити, що ячейка, на яку клікнули, порожня. Інакше нічого не робимо та відправляємо користувача на ту саму сторінку без змін параметрів сесії.
    • Оскільки кількість клітин на полі непарна, можлива ситуація, коли хрестик вдалося поставити, а для нулика вже немає місця. Тому, після того як поставили хрестик, намагаємося отримати індекс незайнятої ячейки (метод getEmptyFieldIndex класу Field). Якщо індекс не негативний, тоді ставимо туди нулик. Код:
      
      package com.tictactoe;
      
      import javax.servlet.RequestDispatcher;
      import javax.servlet.ServletException;
      import javax.servlet.annotation.WebServlet;
      import javax.servlet.http.HttpServlet;
      import javax.servlet.http.HttpServletRequest;
      import javax.servlet.http.HttpServletResponse;
      import javax.servlet.http.HttpSession;
      import java.io.IOException;
      import java.util.List;
      
      @WebServlet(name = "LogicServlet", value = "/logic")
      public class LogicServlet extends HttpServlet {
          @Override
          protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
              // Отримуємо поточну сесію
              HttpSession currentSession = req.getSession();
      
              // Отримуємо об'єкт ігрового поля з сесії
              Field field = extractField(currentSession);
      
              // Отримуємо індекс ячейки, на яку відбувся клік
              int index = getSelectedIndex(req);
              Sign currentSign = field.getField().get(index);
      
              // Перевіряємо, що ячейка, на яку відбувся клік, порожня.
              // В іншому випадку нічого не робимо і посилаємо користувача на ту ж саму сторінку без змін
              // параметрів у сесії
              if (Sign.EMPTY != currentSign) {
                  RequestDispatcher dispatcher = getServletContext().getRequestDispatcher("/index.jsp");
                  dispatcher.forward(req, resp);
                  return;
              }
      
              // Ставимо хрестик в ячейці, на яку клікнув користувач
              field.getField().put(index, Sign.CROSS);
      
              // Отримуємо порожню ячейку поля
              int emptyFieldIndex = field.getEmptyFieldIndex();
      
              if (emptyFieldIndex >= 0) {
                  field.getField().put(emptyFieldIndex, Sign.NOUGHT);
              }
      
              // Рахуємо список значків
              List<Sign> data = field.getFieldData();
      
              // Оновлюємо об'єкт поля і список значків у сесії
              currentSession.setAttribute("data", data);
              currentSession.setAttribute("field", field);
      
              resp.sendRedirect("/index.jsp");
          }
      
          private int getSelectedIndex(HttpServletRequest request) {
              String click = request.getParameter("click");
              boolean isNumeric = click.chars().allMatch(Character::isDigit);
              return isNumeric ? Integer.parseInt(click) : 0;
          }
      
          private Field extractField(HttpSession currentSession) {
              Object fieldAttribute = currentSession.getAttribute("field");
              if (Field.class != fieldAttribute.getClass()) {
                  currentSession.invalidate();
                  throw new RuntimeException("Session is broken, try one more time");
              }
              return (Field) fieldAttribute;
          }
      }
      
  14. На цьому етапі можна ставити хрестики, AI відповідає нуликами. Але немає перевірки того, коли варто зупинити гру. Це може бути у трьох випадках:
    • після чергового ходу хрестика утворилася лінія з трьох хрестиків;
    • після чергового ходу у відповідь нуліком утворилася лінія з трьох нуликів;
    • після чергового ходу хрестика закінчилися порожні клітинки.
    Додамо метод, який перевіряє, чи немає трьох хрестиків/нуліків в один ряд:
    
    /**
     * Метод перевіряє, чи нема трьох хрестиків/нуликів в один ряд.
     * Повертає true/false
     */
    private boolean checkWin(HttpServletResponse response, HttpSession currentSession, Field field) throws IOException {
        Sign winner = field.checkWin();
        if (Sign.CROSS == winner || Sign.NOUGHT == winner) {
            // Додаємо прапорець, який показує, що хтось переміг
            currentSession.setAttribute("winner", winner);
    
            // Рахуємо список значків
            List<Sign> data = field.getFieldData();
    
            // Оновлюємо цей список у сесії
            currentSession.setAttribute("data", data);
    
            // Шлемо редирект
            response.sendRedirect("/index.jsp");
            return true;
        }
        return false;
    }
    
    Особливість цього методу полягає в тому, що в разі коли переможець знайшовся ми додаємо в сесію ще один параметр, за допомогою якого ми змінимо відображення в “index.jsp” у наступних пунктах.
  15. Додамо до методу "doGet" двічі виклик методу "checkWin". Перший раз після встановлення хрестика, другий – після встановлення нулика.
    
    // Перевіряємо, чи не переміг хрестик після додавання останнього кліку користувача
    if (checkWin(resp, currentSession, field)) {
        return;
    }
    
    
    if (emptyFieldIndex >= 0) {
        field.getField().put(emptyFieldIndex, Sign.NOUGHT);
        // Перевіряємо, чи не переміг нулик після додавання останнього нулика
        if (checkWin(resp, currentSession, field)) {
            return;
        }
    }
    
  16. У поведінці майже нічого не змінилося (окрім того, що у разі перемоги одного зі знаків перестають ставитися нулики. Давай у “index.jsp” використаємо параметр “winner” та виведемо переможця. Використовуємо директиви c:set і c:if після таблиці:
    
    <hr> 
    <c:set var="CROSSES" value="<%=Sign.CROSS%>"/> 
    <c:set var="NOUGHTS" value="<%=Sign.NOUGHT%>"/> 
     
    <c:if test="${winner == CROSSES}"> 
        <h1>CROSSES WIN!</h1> 
    </c:if> 
    <c:if test="${winner == NOUGHTS}"> 
        <h1>NOUGHTS WIN!</h1> 
    </c:if> 
    
    Якщо переможуть хрестики, виведеться повідомлення “CROSSES WIN!”, якщо нулі – “NOUGHTS WIN!”. У результаті можемо отримати один із двох написів:
  17. Якщо є переможець, потрібно мати змогу взяти реванш. Для цього необхідна кнопка, яка надішле на сервер запит. А сервер інвалідує поточну сесію та перенаправить запит знову на “/start”.
    • У “index.jsp” у секції “head” пропишемо скрипт “jquery”. За допомогою цієї бібліотеки ми будемо надсилати запит на сервер.
      
      <script src="<c:url value="/static/jquery-3.6.0.min.js"/>"></script> 
      
    • У “index.jsp” у секції “script” додамо функцію, яка вміє надсилати POST запит на сервер. Функцію зробимо синхронною, і коли прийде відповідь з сервера, вона перезавантажить поточну сторінку.
      
      <script> 
          function restart() { 
              $.ajax({ 
                  url: '/restart', 
                  type: 'POST', 
                  contentType: 'application/json;charset=UTF-8', 
                  async: false, 
                  success: function () { 
                      location.reload(); 
                  } 
              }); 
          } 
      </script> 
      
    • Усередині блоків “c:if” додамо кнопку, при натисканні на яку викликається щойно написана функція:
      
      <c:if test="${winner == CROSSES}"> 
          <h1>CROSSES WIN!</h1> 
          <button onclick="restart()">Start again</button> 
      </c:if> 
      <c:if test="${winner == NOUGHTS}"> 
          <h1>NOUGHTS WIN!</h1> 
          <button onclick="restart()">Start again</button> 
      </c:if> 
      
    • Створимо новий сервлет, який обслуговуватиме URL “/restart”.
      
      package com.tictactoe;
      
      import javax.servlet.annotation.WebServlet;
      import javax.servlet.http.HttpServlet;
      import javax.servlet.http.HttpServletRequest;
      import javax.servlet.http.HttpServletResponse;
      import java.io.IOException;
      
      @WebServlet(name = "RestartServlet", value = "/restart")
      public class RestartServlet extends HttpServlet {
          @Override
          protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
              req.getSession().invalidate();
              resp.sendRedirect("/start");
          }
      }
      
      Після перемоги з'явиться кнопка "Start again". Після натискання на неї поле повністю очиститься, і гра почнеться спочатку.
  18. Залишилося розглянути останню ситуацію. Що якщо хрестик користувач поставив, перемоги не сталося, і для нуля немає місця? Тоді це нічия. Саме її зараз і опрацюємо:
    • У "LogicServlet" до сесії додамо ще один параметр "draw", оновимо поле "data" і надішлемо редирект на "index.jsp":
      
      // Якщо така ячейка є
      if (emptyFieldIndex >= 0) {
          …
      }
      // Якщо порожньої ячейки нема і ніхто не переміг – це нічия
      else {
          // Додаємо до сесії прапорець, який сигналізує, що відбулася нічия
          currentSession.setAttribute("draw", true);
      
          // Рахуємо список значків
          List<Sign> data = field.getFieldData();
      
          // Оновлюємо цей список у сесії
          currentSession.setAttribute("data", data);
      
          // Шлемо редирект
          response.sendRedirect("/index.jsp");
          return;
      }
      
    • У “index.jsp” обробимо цей параметр:
      
      <c:if test="${draw}">
          <h1>IT'S A DRAW</h1>
          <br>
          <button onclick="restart()">Start again</button>
      </c:if>
      
      В результаті нічиєї отримаємо відповідне повідомлення та пропозицію почати спочатку:

На цьому написання гри завершено.

Код класів та файлів, з якими ми працювали

InitServlet


package com.tictactoe;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.List;
import java.util.Map;

@WebServlet(name = "InitServlet", value = "/start")
public class InitServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Створення нової сесії
        HttpSession currentSession = req.getSession(true);

        // Створення ігрового поля
        Field field = new Field();
        Map<Integer, Sign> fieldData = field.getField();

        // Отримання списку значень поля
        List<Sign> data = field.getFieldData();

        // Додавання до сесії параметрів поля (потрібно буде для зберігання стану між запитами)
        currentSession.setAttribute("field", field);
        // та значень поля, що відсортовані за індексом (потрібно для промальовки хрестиків і нуликів)
        currentSession.setAttribute("data", data);

        // Перенаправлення запиту на сторінку index.jsp через сервер
        getServletContext().getRequestDispatcher("/index.jsp").forward(req, resp);
    }
}

LogicServlet


package com.tictactoe;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.List;

@WebServlet(name = "LogicServlet", value = "/logic")
public class LogicServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Отримуємо поточну сесію
        HttpSession currentSession = req.getSession();

        // Отримуємо об'єкт ігрового поля з сесії
        Field field = extractField(currentSession);

        // Отримуємо індекс ячейки, на яку відбувся клік
        int index = getSelectedIndex(req);
        Sign currentSign = field.getField().get(index);

        // Перевіряємо, що ячейка, на яку клікнули, порожня.
        // В іншому випадку нічого не робимо і направляємо користувача на ту ж сторінку без змін
        // параметрів у сесії
        if (Sign.EMPTY != currentSign) {
            RequestDispatcher dispatcher = getServletContext().getRequestDispatcher("/index.jsp");
            dispatcher.forward(req, resp);
            return;
        }

        // ставимо хрестик у ячейці, по якій клікнув користувач
        field.getField().put(index, Sign.CROSS);

        // Перевіряємо, чи не переміг хрестик після додавання останнього кліка користувача
        if (checkWin(resp, currentSession, field)) {
            return;
        }

        // Отримуємо порожню ячейку поля
        int emptyFieldIndex = field.getEmptyFieldIndex();

        if (emptyFieldIndex >= 0) {
            field.getField().put(emptyFieldIndex, Sign.NOUGHT);
            // Перевіряємо, чи не переміг нулик після додавання останнього нулика
            if (checkWin(resp, currentSession, field)) {
                return;
            }
        }
        // Якщо порожньої ячейки нема і ніхто не переміг – це нічия
        else {
            // Додаємо до сесії прапорець, який сигналізує, що відбулася нічия
            currentSession.setAttribute("draw", true);

            // Рахуємо список значків
            List<Sign> data = field.getFieldData();

            // Оновлюємо цей список в сесії
            currentSession.setAttribute("data", data);

            // Шлемо редирект
            resp.sendRedirect("/index.jsp");
            return;
        }

        // Рахуємо список значків
        List<Sign> data = field.getFieldData();

        // Оновлюємо об'єкт поля і список значків у сесії
        currentSession.setAttribute("data", data);
        currentSession.setAttribute("field", field);

        resp.sendRedirect("/index.jsp");
    }

    /**
     * Метод перевіряє, чи нема трьох хрестиків/нуликов в ряд.
     * Повертає true/false
     */
    private boolean checkWin(HttpServletResponse response, HttpSession currentSession, Field field) throws IOException {
        Sign winner = field.checkWin();
        if (Sign.CROSS == winner || Sign.NOUGHT == winner) {
            // Додаємо прапорець, який показує, що хтось переміг
            currentSession.setAttribute("winner", winner);

            // Рахуємо список значків
            List<Sign> data = field.getFieldData();

            // Оновлюємо цей список у сесїі
            currentSession.setAttribute("data", data);

            // Шлемо редирект
            response.sendRedirect("/index.jsp");
            return true;
        }
        return false;
    }

    private int getSelectedIndex(HttpServletRequest request) {
        String click = request.getParameter("click");
        boolean isNumeric = click.chars().allMatch(Character::isDigit);
        return isNumeric ? Integer.parseInt(click) : 0;
    }

    private Field extractField(HttpSession currentSession) {
        Object fieldAttribute = currentSession.getAttribute("field");
        if (Field.class != fieldAttribute.getClass()) {
            currentSession.invalidate();
            throw new RuntimeException("Session is broken, try one more time");
        }
        return (Field) fieldAttribute;
    }
}

RestartServlet


package com.tictactoe;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "RestartServlet", value = "/restart")
public class RestartServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        req.getSession().invalidate();
        resp.sendRedirect("/start");
    }
}

index.jsp


<%@ page import="com.tictactoe.Sign" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<!DOCTYPE html>
<html>
<head>
    <link href="static/main.css" rel="stylesheet">
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    <script src="<c:url value="/static/jquery-3.6.0.min.js"/>"></script>
    <title>Tic-Tac-Toe</title>
</head>
<body>
<h1>Tic-Tac-Toe</h1>

<table>
    <tr>
        <td onclick="window.location='/logic?click=0'">${data.get(0).getSign()}</td>
        <td onclick="window.location='/logic?click=1'">${data.get(1).getSign()}</td>
        <td onclick="window.location='/logic?click=2'">${data.get(2).getSign()}</td>
    </tr>
    <tr>
        <td onclick="window.location='/logic?click=3'">${data.get(3).getSign()}</td>
        <td onclick="window.location='/logic?click=4'">${data.get(4).getSign()}</td>
        <td onclick="window.location='/logic?click=5'">${data.get(5).getSign()}</td>
    </tr>
    <tr>
        <td onclick="window.location='/logic?click=6'">${data.get(6).getSign()}</td>
        <td onclick="window.location='/logic?click=7'">${data.get(7).getSign()}</td>
        <td onclick="window.location='/logic?click=8'">${data.get(8).getSign()}</td>
    </tr>
</table>

<hr>
<c:set var="CROSSES" value="<%=Sign.CROSS%>"/>
<c:set var="NOUGHTS" value="<%=Sign.NOUGHT%>"/>

<c:if test="${winner == CROSSES}">
    <h1>CROSSES WIN!</h1>
    <button onclick="restart()">Start again</button>
</c:if>
<c:if test="${winner == NOUGHTS}">
    <h1>NOUGHTS WIN!</h1>
    <button onclick="restart()">Start again</button>
</c:if>
<c:if test="${draw}">
    <h1>IT'S A DRAW</h1>
    <button onclick="restart()">Start again</button>
</c:if>

<script>
    function restart() {
        $.ajax({
            url: '/restart',
            type: 'POST',
            contentType: 'application/json;charset=UTF-8',
            async: false,
            success: function () {
                location.reload();
            }
        });
    }
</script>

</body>
</html>

main.css


td {
    border: 3px solid black;
    padding: 10px;
    border-collapse: separate;
    margin: 10px;
    width: 100px;
    height: 100px;
    font-size: 50px;
    text-align: center;
    empty-cells: show;
   }