Компас D-Lab · занятие 5

Столкновение шарика с треугольником

Сравниваем приближённые и точные способы обнаружения столкновений: охватывающую окружность, контрольные точки, проверку траектории и встроенные средства Canvas.

55 + 10 + 55 минут
Введение · 5 минут

От круга к многоугольнику

Столкновение двух кругов определяется одним условием. Для треугольника такого простого условия нет. На этом занятии сравниваются четыре способа обнаружения столкновения: от быстрого приближённого до точного.

2

Основные результаты

  • построить охватывающую окружность треугольника;
  • проверить столкновение по контрольным точкам на сторонах;
  • определить, по какую сторону от прямой лежит точка;
  • обнаружить пересечение траектории шарика со стороной треугольника;
  • сравнить точность и трудоёмкость методов;
  • перенести один из методов в собственный проект.

Вершины треугольника можно перетаскивать мышью. Выберите метод и увеличьте скорость: при большой скорости приближённые методы начинают ошибаться.

Справочник · JavaScript

Конструкции, которые понадобятся на занятии

Справочник не изучается отдельно. Возвращайтесь к нему, когда в тексте или в заготовке встречается незнакомая запись.

ЗаписьНазначение
{ a: { x: 10, y: 20 } }Объект, свойство которого само является объектом.
tri.a.xКоордината x вершины a объекта tri. Точки читаются слева направо: сначала объект, затем его свойство.
return выражение;Завершает функцию и возвращает значение выражения в место вызова.
return { x: cx, y: cy };Функция возвращает новый объект.
[[a, b], [b, c]]Массив, элементы которого тоже являются массивами.
sides[1][0]Первый элемент второго элемента массива sides. Нумерация начинается с нуля.
ЗаписьНазначение
Math.max(a, b, c)Возвращает наибольшее из чисел.
Math.sqrt(v)Возвращает квадратный корень числа.
Math.abs(v)Возвращает модуль числа: Math.abs(-3) равно 3.
a && bЛогическое «и»: истинно, только если истинны оба условия.
a || bЛогическое «или»: истинно, если истинно хотя бы одно условие.
ctx.setLineDash([6, 4])Задаёт пунктир: штрих 6 пикселей, промежуток 4 пикселя. Пустой массив возвращает сплошную линию.
new Path2D()Создаёт объект-контур, который можно рисовать и проверять.
ctx.isPointInPath(path, x, y)Возвращает true, если точка (x, y) лежит внутри контура.
Изученные ранее функции, которые используются в заготовках
function distance(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

function isColliding(a, b) {
  return distance(a, b) <= a.r + b.r;
}
Эмулятор. В практикумах код выполняется прямо на странице, в изолированной области. Кнопка «Запустить» или сочетание Ctrl + Enter выполняет программу и проверяет задания. Сообщения об ошибках выводятся в консоль под рисунком; щелчок по номеру строки выделяет её в редакторе. Клавиша Tab в редакторе вставляет отступ; чтобы перейти к следующему элементу страницы, нажмите сначала Esc.
Актуализация · 6 минут

Повторение занятия 4

выбор · сборка · порядок

Фрагменты перетаскиваются мышью в пропуски. Можно также выбрать фрагмент щелчком, а затем щёлкнуть по пропуску. Повторный щелчок по заполненному пропуску возвращает фрагмент на место.

1. В массиве particles три частицы с индексами 0, 1 и 2. Какие пары (i, j) будут проверены при выполнении этого кода?
for (let i = 0; i < particles.length; i++) {
  for (let j = i + 1; j < particles.length; j++) {
    // проверка пары (i, j)
  }
}
Выберите один ответ.
2. Соберите функцию вычисления расстояния между центрами.
function distance(a, b) {
  const dx = ;
  const dy = ;
  return Math.sqrt();
}
3. Расположите команды одного шага анимации в правильном порядке.
  1. ctx.clearRect(0, 0, canvas.width, canvas.height);— очистить холст
  2. bounceWalls();— отразить частицы, вышедшие за границы холста
  3. drawParticles();— нарисовать частицы в новых положениях
  4. moveParticles();— изменить координаты частиц на величину скорости
  5. findCollisions();— отметить частицы, которые столкнулись друг с другом
4. Расстояние между центрами двух частиц равно 25, радиусы равны 10 и 12. Что можно утверждать?
Выберите один ответ.
Новая тема · 4 минуты

Почему треугольник сложнее круга

Круг полностью задаётся центром и радиусом. Поэтому для двух кругов достаточно сравнить расстояние между центрами с суммой радиусов. Треугольник задаётся тремя вершинами, а его граница состоит из трёх отрезков. Расстояние от шарика до такой границы зависит от того, к какой стороне или вершине шарик расположен ближе.

Треугольник в программе

Каждая вершина хранится как объект с координатами, а треугольник — как объект из трёх вершин.

const triangle = {
  a: { x: 330, y: 45 },
  b: { x: 450, y: 270 },
  c: { x: 210, y: 235 }
};

triangle.b.y   // 270

Два вида ошибок

Ложное срабатывание: программа сообщает о столкновении, хотя шарик препятствия не касается.

Пропуск столкновения: шарик задел препятствие или прошёл сквозь него, а программа этого не обнаружила.

Приближённые методы допускают ошибки одного или обоих видов. Их достоинство — простота и скорость вычислений.

столкновение?
Ложное срабатывание: шарик вошёл в окружность, но треугольника не касается.
шаг k шаг k + 1
Пропуск столкновения: программа проверяет только отдельные положения шарика. За один шаг быстрый шарик оказался по другую сторону треугольника.
МетодИдеяХарактер результата
1. Охватывающая окружностьЗаменить треугольник окружностью.Приближённый
2. Контрольные точкиПроверить набор точек на сторонах.Приближённый; точность зависит от числа точек
3. ТраекторияПроверить, пересекает ли путь центра шарика сторону треугольника.Точный для траектории центра
4. isPointInPathПоручить проверку точек браузеру.Приближённый; зависит от выбора точек
Метод 1 · теория · 5 минут

Охватывающая окружность

Приближённый метод заменяет сложную фигуру простой. Треугольник помещают внутрь окружности и проверяют столкновение шарика с этой окружностью. Условие столкновения двух кругов уже известно.

Алгоритм

  1. Найдите центр окружности. Его координаты равны средним арифметическим координат вершин. Эта точка называется центроидом треугольника.
  2. Найдите радиус: расстояние от центра до самой удалённой вершины. Тогда все три вершины, а значит, и весь треугольник лежат внутри окружности.
  3. Столкновение обнаружено, если расстояние между центром шарика и центром окружности не больше суммы их радиусов.
function boundingCircle(tri) {
  const cx = (tri.a.x + tri.b.x + tri.c.x) / 3;
  const cy = (tri.a.y + tri.b.y + tri.c.y) / 3;
  const center = { x: cx, y: cy };
  const r = Math.max(
    distance(center, tri.a),
    distance(center, tri.b),
    distance(center, tri.c)
  );
  return { x: cx, y: cy, r: r };
}

function hitCircle(p, tri) {
  const c = boundingCircle(tri);
  return distance(p, c) <= p.r + c.r;
}
Охватывающая и описанная окружности. В геометрии описанной называют окружность, которая проходит через все три вершины треугольника. Её центр вычислять сложнее. Для обнаружения столкновения достаточно, чтобы треугольник целиком лежал внутри окружности, поэтому используется более простая конструкция с центром в центроиде.
Ограничение метода. Шарик может войти в окружность, не касаясь треугольника. Чем сильнее треугольник отличается от круга, например чем он уже и длиннее, тем больше ложных срабатываний.
Метод 1 · тренировка · 3 минуты

Тренировка: охватывающая окружность

1. Заполните пропуски в функциях метода.
const cx = (tri.a.x + tri.b.x + tri.c.x) / ;
const r = Math.(
  distance(center, tri.a),
  distance(center, tri.b),
  distance(center, tri.c)
);
return distance(p, c)  p.r + c.r;
2. Вершины треугольника: (0, 0), (6, 0) и (0, 9). Где находится центр охватывающей окружности?
Выберите один ответ.
3. Почему радиус равен наибольшему, а не наименьшему расстоянию до вершин?
Выберите один ответ.
4. Шарик вошёл в охватывающую окружность, но не коснулся треугольника. Как называется такая ошибка?
Выберите один ответ.
Метод 1 · практикум · 6 минут

Практикум: охватывающая окружность

Дополните заготовку. После каждого запуска эмулятор проверяет поведение программы и отмечает выполненные задания. Вершины треугольника можно перетаскивать мышью прямо на рисунке.

Метод 2 · теория · 6 минут

Контрольные точки на сторонах

Второй приближённый метод проверяет не всю границу треугольника, а набор точек на ней. Если хотя бы одна точка оказалась внутри шарика, столкновение считается обнаруженным. Для каждой точки применяется известное условие: расстояние от точки до центра шарика не больше радиуса.

Точка на отрезке

Точка отрезка AB задаётся числом t от 0 до 1. Число показывает, какую долю пути от A до B нужно пройти.

x = a.x + (b.x − a.x) · t
y = a.y + (b.y − a.y) · t

При t = 0 получается точка A, при t = 1 — точка B, при t = 0.5 — середина отрезка.

function pointOnSegment(a, b, t) {
  return {
    x: a.x + (b.x - a.x) * t,
    y: a.y + (b.y - a.y) * t
  };
}

function triangleSides(tri) {
  return [[tri.a, tri.b], [tri.b, tri.c], [tri.c, tri.a]];
}

function sidePoints(a, b, n) {
  const points = [];
  for (let k = 0; k <= n; k++) {
    points.push(pointOnSegment(a, b, k / n));
  }
  return points;
}
function hitPoints(p, tri, n) {
  const sides = triangleSides(tri);
  for (let s = 0; s < sides.length; s++) {
    const points = sidePoints(sides[s][0], sides[s][1], n);
    for (let k = 0; k < points.length; k++) {
      if (distance(p, points[k]) <= p.r) {
        return true;
      }
    }
  }
  return false;
}

Как работает функция

Внешний цикл перебирает три стороны, внутренний — точки текущей стороны.

Команда return true внутри цикла сразу завершает функцию: одной найденной точки достаточно, остальные проверять не нужно.

Команда return false выполняется только тогда, когда циклы перебрали все точки и ни одна не подошла.

3

Исследование

Перетащите шарик к середине стороны между двумя точками. Затем увеличивайте n, пока касание не будет обнаружено.

Ограничения метода. Расстояние между соседними точками называется шагом. Если шаг больше диаметра шарика (2r), шарик может пройти между точками. Кроме того, метод проверяет только границу: если за один шаг анимации шарик целиком оказался внутри треугольника, ни одна точка в него не попадёт. Увеличение n уменьшает шаг, но увеличивает объём вычислений: на каждую частицу приходится 3 · (n + 1) проверок расстояния.
Метод 2 · тренировка · 4 минуты

Тренировка: контрольные точки на сторонах

1. Чему равен результат pointOnSegment({x: 0, y: 0}, {x: 100, y: 40}, 0.25)?
Выберите один ответ.
2. Заполните пропуски в функции sidePoints, чтобы на стороне получилось n + 1 точек от A до B.
for (let k = 0; k  n; k++) {
  points.push(pointOnSegment(a, b, ));
}
3. Расположите шаги алгоритма метода контрольных точек в правильном порядке.
  1. Для каждой точки вычислить расстояние до центра шарика.
  2. Если ни одна точка не подошла, вернуть false.
  3. Составить массив из трёх сторон треугольника.
  4. Если расстояние не больше радиуса, вернуть true.
  5. Для каждой стороны построить контрольные точки.
4. Длина стороны равна 200, n = 10, радиус шарика равен 8. Может ли шарик пройти через сторону между точками?
Выберите один ответ.
Метод 2 · практикум · 10 минут

Практикум: контрольные точки на сторонах

Дополните заготовку. Когда все задания будут выполнены, измените значение POINTS_N на 2 и запустите программу снова. Объясните, почему некоторые частицы проходят сквозь стороны.

Собственный проект · 6 минут

Добавьте треугольное препятствие в свой проект

Эмулятор нужен для изучения метода. Основной проект по-прежнему хранится в файлах на компьютере. Перенесите в него метод охватывающей окружности.

Проект к началу занятия. Если ваш проект не работает или остался незавершённым после занятия 4, скачайте архив с готовой основой и продолжайте с ним. Внутри папка project с тремя файлами: index.html (холст и панель управления), style.css (оформление) и script.js (частицы отражаются от стенок, столкновения отмечаются красным, работают пауза и ползунок скорости). Распакуйте архив и работайте с файлами из папки project.

Порядок действий

  1. Откройте папку проекта. Откройте script.js в Notepad++, а index.html — в браузере.
  2. Скопируйте функции метода кнопкой ниже и вставьте их в script.js перед функцией findTriangleHits. Функция distance в проекте уже есть, поэтому её повтор из скопированного фрагмента удалите.
  3. Измените координаты вершин в объекте triangle так, чтобы треугольник поместился на холсте 600 × 300.
  4. В функции draw после очистки холста вызовите drawTriangle(triangle).
  5. В функции findTriangleHits(tri) раскомментируйте строку p.hitTri = hitCircle(p, tri);. Результат записывается в отдельное свойство hitTri, поэтому отметки hit о столкновениях частиц друг с другом сохраняются.
  6. В функции tick после findCollisions(); вызовите findTriangleHits(triangle);. Функция drawParticle уже окрашивает частицы с hitTri в оранжевый цвет.
  7. Сохраните файл сочетанием Ctrl + S и обновите страницу клавишей F5.

Проверка

Имена свойств. Функции ожидают объект со свойствами x, y и r. Если в вашем проекте радиус называется иначе, например size, переименуйте свойство или передайте в функцию объект с нужными именами.
Перерыв · 10 минут

Итоги первой части

Изучено

Два приближённых метода. Охватывающая окружность требует одной проверки расстояния, но даёт ложные срабатывания. Контрольные точки точнее, но требуют больше вычислений и пропускают быстрые шарики.

После перерыва

Точный метод: проверка траектории. Программа будет определять не только факт столкновения, но и сторону треугольника, о которую ударился шарик.

Метод 3 · сторона от прямой · 6 минут

По какую сторону от прямой лежит точка

Прямая, проходящая через точки A и B, делит плоскость на две части. Чтобы определить, в какой из них лежит точка P, вычисляют значение выражения:

function side(a, b, p) {
  return (b.x - a.x) * (p.y - a.y) -
         (b.y - a.y) * (p.x - a.x);
}
Значение side(a, b, p)Положение точки P
больше 0по одну сторону от прямой AB
меньше 0по другую сторону от прямой AB
равно 0на прямой AB

Исследование

Перетаскивайте точки A, B и P. Области по разные стороны от прямой окрашены в разные цвета.

Найдите положение P, при котором значение равно нулю. Что происходит со знаком, если поменять местами A и B?

Смысл выражения. Модуль значения side(a, b, p) равен удвоенной площади треугольника ABP, а знак показывает направление поворота от вектора AB к вектору AP. В математике такое выражение называют векторным произведением (точнее, его единственной ненулевой координатой для векторов на плоскости). На Canvas ось y направлена вниз, поэтому положительное значение соответствует точкам, которые лежат справа, если смотреть из A в сторону B.
Почему не уравнение y = kx + b. Уравнение прямой из курса алгебры не подходит для вертикальной прямой: для неё коэффициент k не определён, так как при его вычислении пришлось бы делить на ноль. Выражение side не содержит деления и работает при любом расположении точек.
Метод 3 · пересечение отрезков · 6 минут

Пересекает ли траектория сторону треугольника

За один шаг центр шарика перемещается из точки P1 = (x, y) в точку P2 = (x + vx, y + vy). Отрезок P1P2 называется траекторией шага. Если траектория пересекла сторону AB, шарик столкнулся с этой стороной, даже если ни в одном проверенном положении он стороны не касался.

Условие пересечения двух отрезков

Отрезки P1P2 и AB пересекаются, если одновременно выполнены два условия:

  1. точки P1 и P2 лежат по разные стороны от прямой AB;
  2. точки A и B лежат по разные стороны от прямой P1P2.

Две точки лежат по разные стороны от прямой, если значения side для них имеют разные знаки, то есть их произведение отрицательно.

Исследование

Перетаскивайте точки. Найдите положение, при котором первое условие выполнено, а второе нет. Как при этом расположена траектория относительно отрезка AB?

function segmentsCross(p1, p2, a, b) {
  const d1 = side(a, b, p1);
  const d2 = side(a, b, p2);
  const d3 = side(p1, p2, a);
  const d4 = side(p1, p2, b);
  return d1 * d2 < 0 && d3 * d4 < 0;
}

function crossedSide(p, tri) {
  const next = { x: p.x + p.vx, y: p.y + p.vy };
  const sides = triangleSides(tri);
  for (let s = 0; s < sides.length; s++) {
    if (segmentsCross(p, next, sides[s][0], sides[s][1])) {
      return s;
    }
  }
  return -1;
}

Пояснения

Одного первого условия недостаточно. Точки P1 и P2 могут лежать по разные стороны от прямой AB, но траектория при этом проходит мимо отрезка AB, за его концом.

Функция crossedSide возвращает номер пересечённой стороны: 0 — AB, 1 — BC, 2 — CA. Значение -1 означает, что пересечения нет. Такое соглашение принято, потому что номер стороны не может быть отрицательным.

Строгое неравенство < 0 не учитывает точного касания, например прохождения траектории через вершину. При дробных координатах такие случаи редки, и в учебной модели ими можно пренебречь.

Реакция на столкновение: отражение от наклонной стороны

Номер стороны позволяет выбрать реакцию: подсветить сторону, изменить счёт или отразить шарик. Отражение выполняет готовая функция reflect. Скорость шарика раскладывается на две составляющие: вдоль стороны и перпендикулярно ей. Составляющая вдоль стороны сохраняется, а перпендикулярная меняет знак. Для горизонтальной стороны результат совпадает с известной командой p.vy = -p.vy.

function reflect(p, a, b) {
  const len = distance(a, b);
  const nx = -(b.y - a.y) / len; // единичный вектор,
  const ny = (b.x - a.x) / len;  // перпендикулярный стороне
  const dot = p.vx * nx + p.vy * ny;
  p.vx = p.vx - 2 * dot * nx;
  p.vy = p.vy - 2 * dot * ny;
}
Учёт радиуса. Функция проверяет траекторию центра шарика. Поэтому до обнаружения столкновения шарик успевает погрузиться в сторону на величину радиуса. Устранить этот эффект можно, если сдвинуть каждую сторону наружу на r. Это дополнительная задача практикума.
Метод 3 · тренировка · 5 минут

Тренировка: пересечение траектории со сторонами треугольника

1. Вычислите side(A, B, P) для A(0, 0), B(10, 0) и P(3, 4).
Выберите один ответ.
2. Для точки P выражение side(A, B, P) равно 0. Что это означает?
Выберите один ответ.
3. Соберите условие пересечения отрезков.
return d1 * d2   d3 * d4 ;
4. Расположите строки функции crossedSide в правильном порядке.
  1. for (let s = 0; s < sides.length; s++) {
  2. return -1;
  3. const next = { x: p.x + p.vx, y: p.y + p.vy };
  4. if (segmentsCross(p, next, sides[s][0], sides[s][1])) return s;
  5. const sides = triangleSides(tri);
  6. }
5. Почему недостаточно проверить, что P1 и P2 лежат по разные стороны от прямой AB?
Выберите один ответ.
6. Сопоставьте значение, которое вернула crossedSide, и его смысл.
0
1
2
-1
Метод 3 · практикум · 18 минут

Практикум: пересечение траектории со сторонами треугольника

Первая частица заготовки движется очень быстро. Пока функции не дописаны, она пролетает сквозь треугольник. После выполнения заданий она должна отражаться от сторон, а пересечённая сторона — подсвечиваться.

Дополнительные задачи
  1. Счётчик ударов. Создайте массив hits = [0, 0, 0] и увеличивайте элемент с номером пересечённой стороны. Выводите значения в консоль командой console.log(hits).
  2. Учёт радиуса. Перед проверкой сдвиньте концы стороны наружу на p.r вдоль перпендикуляра (nx, ny) из функции reflect. Направление «наружу» определяется по знаку side для центроида треугольника.
Метод 4 · теория · 2 минуты

Проверка средствами Canvas

Контекст рисования Canvas умеет сам определять, лежит ли точка внутри фигуры. Фигуру описывают объектом Path2D — контуром, а проверку выполняет метод isPointInPath. Математику выполняет браузер, поэтому метод подходит для фигур любой формы: звезды, многоугольника, фигуры с дугами.

function trianglePath(tri) {
  const path = new Path2D();
  path.moveTo(tri.a.x, tri.a.y);
  path.lineTo(tri.b.x, tri.b.y);
  path.lineTo(tri.c.x, tri.c.y);
  path.closePath();
  return path;
}

function hitPath(p, tri) {
  const path = trianglePath(tri);
  const points = [
    { x: p.x, y: p.y },
    { x: p.x + p.r, y: p.y },
    { x: p.x - p.r, y: p.y },
    { x: p.x, y: p.y + p.r },
    { x: p.x, y: p.y - p.r }
  ];
  for (let k = 0; k < points.length; k++) {
    if (ctx.isPointInPath(path, points[k].x, points[k].y)) {
      return true;
    }
  }
  return false;
}

Достоинства и недостатки

Достоинства: не требуется геометрических формул; форма препятствия может быть любой; тот же контур используется для рисования.

Недостатки: проверяются только пять точек шарика, поэтому шарик может задеть угол треугольника между ними; быстрый шарик по-прежнему может пролететь насквозь; метод не сообщает, о какую сторону произошёл удар.

Метод isPointInPath принадлежит контексту ctx, поэтому функция работает только там, где этот контекст создан.

Метод 4 · практикум · 5 минут

Практикум: проверка средствами Canvas

Обобщение · 5 минут

Сравнение методов

МетодПроверок на одну частицуТочностьТипичная ошибка
1. Окружность1 сравнение расстоянияНизкаяЛожное срабатывание
2. Точки3 · (n + 1) сравненийЗависит от nПропуск между точками; шарик внутри треугольника
3. Траектория3 проверки пересечения отрезковВысокая для центра шарикаПогружение в сторону на радиус, если стороны не сдвинуты
4. isPointInPathдо 5 проверок точекСредняяПропуск при большой скорости или касании углом
Сочетание методов. На практике методы комбинируют. Сначала выполняют быструю грубую проверку, например охватывающей окружностью, а точную проверку выполняют только для объектов, которые прошли первую. Такой подход называют двухфазным: широкая фаза (broad phase) отсеивает далёкие объекты, узкая фаза (narrow phase) точно проверяет оставшиеся. При 50 частицах и одном треугольнике узкая фаза обычно выполняется лишь для нескольких частиц.
Выберите метод для каждой ситуации.
Тысяча медленных частиц, препятствие по форме близко к кругу, важна скорость работы.
Быстрые снаряды; ни одно попадание нельзя пропустить; нужно знать, в какую сторону попал снаряд.
Препятствие в форме звезды; шарики медленные; формулы для сторон писать не хочется.
Собственный проект · 15 минут

Перенесите в проект более точный метод

Выберите метод контрольных точек или метод траектории и замените им проверку окружностью в своём проекте. Метод траектории сложнее, но позволяет отражать объекты от сторон.

Порядок действий

  1. Сохраните текущую работающую версию проекта. Создайте копию script.js с понятным именем, например script-circle.js.
  2. Скопируйте функции выбранного метода и вставьте их в script.js. Функцию distance и объект triangle повторно не вставляйте, если они уже есть.
  3. Метод контрольных точек: в функции findTriangleHits замените hitCircle(p, tri) на hitPoints(p, tri, POINTS_N).
  4. Метод траектории: в функции findTriangleHits вызовите crossedSide(p, tri). Если результат не равен −1, присвойте p.hitTri = true и добавьте реакцию: отражение функцией reflect или подсветку стороны. Вызов findTriangleHits(triangle) перенесите в начало tick: сразу после проверки running и до изменения координат. В проекте частица за шаг сдвигается на p.vx * speed, поэтому в crossedSide вычисляйте next с учётом speed.
  5. Проверьте работу при обычной и при большой скорости объектов.
  6. Сохраните актуальную копию папки проекта в Google Drive.

Проверка

Использование ИИ. Можно попросить ИИ объяснить незнакомую строку, найти причину ошибки или предложить способ встроить функцию в ваш код. Перед вставкой предложенного кода убедитесь, что можете объяснить каждую его строку. Если объяснить не получается, попросите ИИ упростить решение.
Итоги · 5 минут

Проверьте результат занятия

Проект

Объяснение

Обязательный результат: выполненные практикумы методов 1–3 в эмуляторе и хотя бы один метод в собственном проекте. Если перенос в проект не завершён, минимальным результатом считаются выполненные практикумы.

Завершение работы

Нажмите кнопку после проверки всех чек-листов. Результаты тренажёров и практикумов будут отправлены учителю.