commit aece34fe7382d1487a857c1c67c818b3ae1641f5 Author: Vladimir V Maksimov Date: Tue May 12 10:54:09 2026 +0300 feat: initial commit with M1-M4 implementation diff --git a/cmd/game/main.go b/cmd/game/main.go new file mode 100644 index 0000000..bbdee71 --- /dev/null +++ b/cmd/game/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "github.com/soer/football/internal/game" + "github.com/soer/football/internal/render" + "github.com/hajimehoshi/ebiten/v2" + "log" +) + +type Game struct { + world *game.World + renderer *render.Renderer +} + +func (g *Game) Update() error { + g.world.Update() + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + g.renderer.DrawWorld(screen, g.world) +} + +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return 1280, 720 +} + +func main() { + ebiten.SetWindowSize(1280, 720) + ebiten.SetWindowTitle("AI Hockey Simulation") + + g := &Game{ + world: game.NewWorld(), + renderer: render.NewRenderer(), + } + + // Give the puck some initial velocity to see it move + g.world.Puck.Velocity.X = 2 + g.world.Puck.Velocity.Y = 1.5 + + if err := ebiten.RunGame(g); err != nil { + log.Fatal(err) + } +} diff --git a/docs/plans/2026-05-08-hockey-impl.md b/docs/plans/2026-05-08-hockey-impl.md new file mode 100644 index 0000000..cf1249e --- /dev/null +++ b/docs/plans/2026-05-08-hockey-impl.md @@ -0,0 +1,12 @@ +# Roadmap: Hockey Simulation + +**Goal:** Implement an autonomous 6v6 hockey simulation. +**Spec:** [docs/specs/2026-05-08-hockey-design.md](../specs/2026-05-08-hockey-design.md) + +## Milestones +- [ ] M1: Core Engine & Basic Rendering (Window, Rink, Puck, Game Loop) +- [ ] M2: Physics & Puck Dynamics (Collisions, Friction, Bounces) +- [ ] M3: Basic AI & Player Movement (Steering Behaviors, Basic Seek) +- [ ] M4: Tactical AI & Role-based Logic (Zones, Forwards/Defenders/Goalie) +- [ ] M5: Game Rules & Match Logic (Scoring, Resets, Timer, Referee) +- [ ] M6: Polish & Balancing (Tuning weights, Visual improvements) \ No newline at end of file diff --git a/docs/plans/2026-05-08-m1-engine.md b/docs/plans/2026-05-08-m1-engine.md new file mode 100644 index 0000000..7560d00 --- /dev/null +++ b/docs/plans/2026-05-08-m1-engine.md @@ -0,0 +1,187 @@ +# План: M1 - Core Engine & Basic Rendering + +**Цель:** Создать базовое окно игры, отрисовать хоккейную площадку и одну статичную шайбу, настроить основной игровой цикл. + +**Архитектура:** +- Использование Ebitengine для рендеринга. +- Разделение на `World` (состояние) и `Renderer` (отрисовка). +- Точка входа в `cmd/game/main.go`. + +**Стек:** Go, Ebitengine. +**Спека:** [docs/specs/2026-05-08-hockey-design.md](../specs/2026-05-08-hockey-design.md) + +--- + +## Файловая структура + +- `cmd/game/main.go` — точка входа, инициализация Ebitengine. +- `internal/game/world.go` — структура World, хранящая состояние игры. +- `internal/render/renderer.go` — логика отрисовки объектов на экране. +- `internal/entities/puck.go` — определение структуры Puck. + +## Задачи + +### Задача 1: Инициализация проекта и окна + +**Файлы:** +- Создать: `cmd/game/main.go` + +- [ ] **Шаг 1: Минимальный код для запуска окна** +```go +package main + +import ( + "log" + "github.com/hajimehoshi/ebiten/v2" +) + +type Game struct{} + +func (g *Game) Update() error { return nil } +func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(ebiten.ColorWhite) } +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return 800, 600 } + +func main() { + ebiten.SetWindowSize(800, 600) + ebiten.SetWindowTitle("Hockey Sim") + if err := ebiten.RunGame(&Game{}); err != nil { + log.Fatal(err) + } +} +``` + +- [ ] **Шаг 2: Запуск и проверка** +`go run cmd/game/main.go` +Ожидание: Открывается белое окно 800x600. + +- [ ] **Шаг 3: Коммит** +`git add cmd/game/main.go` +`git commit -m "feat(engine): initialize ebitengine window"` + +### Задача 2: Определение сущности Шайбы + +**Файлы:** +- Создать: `internal/entities/puck.go` + +- [ ] **Шаг 1: Структура Puck** +```go +package entities + +type Puck struct { + X, Y float64 +} + +func NewPuck(x, y float64) *Puck { + return &Puck{X: x, Y: y} +} +``` + +- [ ] **Шаг 2: Коммит** +`git add internal/entities/puck.go` +`git commit -m "feat(entities): add puck entity"` + +### Задача 3: Создание игрового мира + +**Файлы:** +- Создать: `internal/game/world.go` + +- [ ] **Шаг 1: Структура World** +```go +package game + +import "football/internal/entities" + +type World struct { + Puck *entities.Puck +} + +func NewWorld() *World { + return &World{ + Puck: entities.NewPuck(400, 300), + } +} +``` + +- [ ] **Шаг 2: Коммит** +`git add internal/game/world.go` +`git commit -m "feat(game): add world state"` + +### Задача 4: Реализация базового рендерера + +**Файлы:** +- Создать: `internal/render/renderer.go` + +- [ ] **Шаг 1: Функция отрисовки мира** +```go +package render + +import ( + "football/internal/game" + "github.com/hajimehoshi/ebiten/v2" + "image/color" +) + +type Renderer struct{} + +func (r *Renderer) DrawWorld(screen *ebiten.Image, world *game.World) { + // Отрисовка льда (белый фон) + screen.Fill(color.RGBA{240, 240, 240, 255}) + + // Отрисовка шайбы (черный квадрат для начала) + // В Ebitengine для простых фигур используем vector или маленькие изображения. + // Для M1 достаточно закрасить область. +} +``` +*Примечание: Для полноценного круга потребуется vector package, но для M1 начнем с простого заполнения.* + +- [ ] **Шаг 2: Коммит** +`git add internal/render/renderer.go` +`git commit -m "feat(render): add basic world renderer"` + +### Задача 5: Интеграция в Game Loop + +**Файлы:** +- Изменить: `cmd/game/main.go` + +- [ ] **Шаг 1: Подключение World и Renderer** +```go +package main + +import ( + "football/internal/game" + "football/internal/render" + "github.com/hajimehoshi/ebiten/v2" + "log" +) + +type Game struct { + world *game.World + renderer *render.Renderer +} + +func (g *Game) Update() error { return nil } +func (g *Game) Draw(screen *ebiten.Image) { + g.renderer.DrawWorld(screen, g.world) +} +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return 800, 600 } + +func main() { + ebiten.SetWindowSize(800, 600) + ebiten.SetWindowTitle("Hockey Sim") + gameInstance := &Game{ + world: game.NewWorld(), + renderer: &render.Renderer{}, + } + if err := ebiten.RunGame(gameInstance); err != nil { + log.Fatal(err) + } +} +``` + +- [ ] **Шаг 2: Запуск и проверка** +`go run cmd/game/main.go` +Ожидание: Окно с серым фоном. + +- [ ] **Шаг 3: Коммит** +`git add cmd/game/main.go` +`git commit -m "feat(engine): integrate world and renderer into loop"` \ No newline at end of file diff --git a/docs/plans/2026-05-08-physics-dynamics.md b/docs/plans/2026-05-08-physics-dynamics.md new file mode 100644 index 0000000..f1edd36 --- /dev/null +++ b/docs/plans/2026-05-08-physics-dynamics.md @@ -0,0 +1,146 @@ +# План: Физика и динамика шайбы (M2) + +**Цель:** Реализовать трение, потерю энергии при отскоках и порог остановки для шайбы. + +**Архитектура:** Внедрение коэффициентов трения (`Friction`) и упругости (`Restitution`) в логику обновления мира. Использование простого множительного затухания скорости. + +**Стек:** Go, Ebitengine (для контекста мира). + +**Спека:** [docs/specs/2026-05-08-physics-dynamics-design.md](../specs/2026-05-08-physics-dynamics-design.md) + +--- + +## Файловая структура + +- `internal/game/world.go` — добавление констант физики и обновление логики `Update`. +- `internal/game/world_test.go` — новые unit-тесты для проверки затухания скорости и потерь при отскоках. + +--- + +## Задачи + +### Задача 1: Константы физики + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Добавить константы в начало файла** + +```go +const ( + PuckFriction = 0.99 // Коэффициент трения (каждый кадр) + PuckRestitution = 0.8 // Коэффициент упругости (при ударе о борт) + PuckStopThreshold = 0.1 // Порог остановки +) +``` + +- [ ] **Шаг 2: Коммит** + +```bash +git add internal/game/world.go +git commit -m "phys: add physics constants" +``` + +### Задача 2: Реализация трения и порога остановки + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Обновить `World.Update` для применения трения** + +В начале `Update` добавить: +```go +w.puck.Velocity.X *= PuckFriction +w.puck.Velocity.Y *= PuckFriction + +if math.Abs(w.puck.Velocity.X) < PuckStopThreshold { + w.puck.Velocity.X = 0 +} +if math.Abs(w.puck.Velocity.Y) < PuckStopThreshold { + w.puck.Velocity.Y = 0 +} +``` + +- [ ] **Шаг 2: Коммит** + +```bash +git add internal/game/world.go +git commit -m "phys: implement puck friction and stop threshold" +``` + +### Задача 3: Реализация потерь при отскоках + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Обновить логику отскоков в `World.Update`** + +Заменить инверсию скорости на инверсию с множителем `PuckRestitution`. +Пример для X: +```go +if w.puck.X - w.puck.Radius <= 0 || w.puck.X + w.puck.Radius >= float64(screenWidth) { + w.puck.Velocity.X = -w.puck.Velocity.X * PuckRestitution + // Добавить коррекцию позиции, чтобы не застрять в стене + if w.puck.X - w.puck.Radius < 0 { + w.puck.X = w.puck.Radius + } else if w.puck.X + w.puck.Radius > float64(screenWidth) { + w.puck.X = float64(screenWidth) - w.puck.Radius + } +} +``` +Аналогично для Y. + +- [ ] **Шаг 2: Коммит** + +```bash +git add internal/game/world.go +git commit -m "phys: implement energy loss on bounce" +``` + +### Задача 4: Тестирование физики + +**Файлы:** +- Создать: `internal/game/world_test.go` + +- [ ] **Шаг 1: Написать тест на затухание скорости** + +```go +func TestPuckFriction(t *testing.T) { + w := NewWorld() + w.puck.Velocity = Vector2{X: 10, Y: 0} + + w.Update() + + if w.puck.Velocity.X >= 10 { + t.Errorf("Expected velocity to decrease due to friction, got %f", w.puck.Velocity.X) + } +} +``` + +- [ ] **Шаг 2: Написать тест на отскок с потерей энергии** + +```go +func TestPuckBounceLoss(t *testing.T) { + w := NewWorld() + w.puck.X = 1 // Почти у левого края + w.puck.Velocity = Vector2{X: -10, Y: 0} + + w.Update() + + expected := 10 * PuckRestitution + if math.Abs(w.puck.Velocity.X - expected) > 0.001 { + t.Errorf("Expected velocity after bounce to be %f, got %f", expected, w.puck.Velocity.X) + } +} +``` + +- [ ] **Шаг 3: Запустить тесты** + +`go test ./internal/game/ -v` + +- [ ] **Шаг 4: Коммит** + +```bash +git add internal/game/world_test.go +git commit -m "test: add physics tests" +``` diff --git a/docs/plans/2026-05-09-players-ai.md b/docs/plans/2026-05-09-players-ai.md new file mode 100644 index 0000000..92b5704 --- /dev/null +++ b/docs/plans/2026-05-09-players-ai.md @@ -0,0 +1,100 @@ +# План: Автономные игроки и ИИ (M3) + +**Цель:** Реализовать автономных игроков с ролевым и зональным ИИ, которые двигаются плавно и соблюдают тактические позиции. + +**Архитектура:** Разделение данных игрока (`Player` entity) и логики поведения. Использование steering-подхода для плавного движения. + +**Стек:** Go, Ebitengine. + +**Спека:** [docs/specs/2026-05-09-players-ai-design.md](../specs/2026-05-09-players-ai-design.md) + +--- + +## Файловая структура + +- `internal/entities/player.go` — новая сущность игрока, типы `Team` и `Role`. +- `internal/game/world.go` — интеграция игроков в мир, логика AI, физика движения. +- `internal/game/world_test.go` — тесты поведения AI. + +--- + +## Задачи + +### Задача 1: Сущность Игрока + +**Файлы:** +- Создать: `internal/entities/player.go` + +- [ ] **Шаг 1: Определить типы и структуру** + - Создать `Team` (Red, Blue) и `Role` (Striker, Defender). + - Создать структуру `Player` с полями: `Position`, `Velocity`, `Radius`, `Team`, `Role`, `HomePosition`, `MaxSpeed`, `Acceleration`. + +- [ ] **Шаг 2: Коммит** + `git add internal/entities/player.go && git commit -m "feat(entities): add player entity"` + +### Задача 2: Интеграция игроков в World + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Добавить константы ворот и список игроков** + - Добавить `GoalLeft` и `GoalRight` (точки в центре левого и правого краев). + - Добавить `Players []*Player` в структуру `World`. + +- [ ] **Шаг 2: Инициализация игроков** + - В `NewWorld` создать по 2 игрока для каждой команды (1 защитник, 1 нападающий) и расставить их по начальным позициям. + +- [ ] **Шаг 3: Коммит** + `git add internal/game/world.go && git commit -m "feat(game): integrate players into world"` + +### Задача 3: Реализация AI и Steering + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Реализовать расчет Target Point** + - Создать метод `calculateTarget(p *Player, puckPos Vector2D) Vector2D`. + - Логика для Защитника: если шайба на чужой половине -> `HomePosition`, иначе -> точка между шайбой и воротами. + - Логика для Нападающего: если шайба в зоне -> позиция шайбы, иначе -> центр/позиция между центром и чужими воротами. + +- [ ] **Шаг 2: Реализовать Steering-физику** + - В `World.Update` для каждого игрока: + 1. Вычислить `Target Point`. + 2. Вычислить `Desired Velocity` (нормализованный вектор к цели * `MaxSpeed`). + 3. Применить ускорение: `Velocity += (DesiredVelocity - Velocity) * factor`, ограничив по `Acceleration`. + 4. Обновить `Position += Velocity`. + 5. Добавить clamping по границам экрана. + +- [ ] **Шаг 3: Добавить Anti-Clumping и Dead Zone** + - Добавить небольшое отталкивание между игроками одной команды. + - Добавить порог остановки, чтобы избежать дрожания. + +- [ ] **Шаг 4: Коммит** + `git add internal/game/world.go && git commit -m "feat(game): implement player AI and steering physics"` + +### Задача 4: Рендеринг игроков + +**Файлы:** +- Изменить: `internal/game/world.go` (или там, где происходит отрисовка) + +- [ ] **Шаг 1: Отрисовка игроков** + - В методе отрисовки добавить цикл по `World.Players`. + - Рисовать круг: Красный для `TeamRed`, Синий для `TeamBlue`. + +- [ ] **Шаг 2: Коммит** + `git add internal/game/world.go && git commit -m "feat(game): render players"` + +### Задача 5: Тестирование и Полировка + +**Файлы:** +- Изменить: `internal/game/world_test.go` + +- [ ] **Шаг 1: Написать тесты на AI** + - Проверить, что защитник возвращается к воротам при удалении шайбы. + - Проверить, что нападающий движется к шайбе. + +- [ ] **Шаг 2: Запустить тесты и убедиться, что проходят** + `go test ./internal/game/... -v` + +- [ ] **Шаг 3: Коммит** + `git add internal/game/world_test.go && git commit -m "test(game): add AI behavior tests"` diff --git a/docs/plans/2026-05-09-tactical-ai.md b/docs/plans/2026-05-09-tactical-ai.md new file mode 100644 index 0000000..8805bc8 --- /dev/null +++ b/docs/plans/2026-05-09-tactical-ai.md @@ -0,0 +1,151 @@ +# План: Тактический ИИ и Ролевая Логика (M4) + +**Цель:** Реализовать роль Вратаря с логикой перекрытия углов и систему зонирования для нападающих и защитников. + +**Архитектура:** +- Введение `RoleGoalie` в перечисление ролей. +- Реализация специализированного метода `updateGoalieAI`, который вычисляет целевую точку на векторе «Центр ворот -> Шайба» с ограничением по радиусу. +- Внедрение системы зон (Защитная, Средняя, Атакующая) для динамического изменения агрессивности игроков. + +**Стек:** Go + +**Спека:** [docs/specs/2026-05-09-tactical-ai-design.md](../specs/2026-05-09-tactical-ai-design.md) + +--- + +## Файловая структура + +- `internal/entities/player.go` — добавление `RoleGoalie`. +- `internal/game/world.go` — константы зон, логика ИИ вратаря и зонирования. +- `internal/game/world_test.go` — тесты на поведение вратаря и смену зон. + +--- + +## Задачи + +### Задача 1: Расширение ролей и константы зон + +**Файлы:** +- Изменить: `internal/entities/player.go` +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Добавить `RoleGoalie` в `PlayerRole`** +```go +type PlayerRole int +const ( + RoleStriker PlayerRole = iota + RoleDefender + RoleGoalie // Добавить это +) +``` + +- [ ] **Шаг 2: Добавить константы зон в `world.go`** +```go +const ( + ZoneWidth = WorldWidth / 3 + GoalieMaxDistance = 80.0 // Максимальный выход вратаря из ворот +) +``` + +- [ ] **Шаг 3: Коммит** +`git add internal/entities/player.go internal/game/world.go` +`git commit -m "feat(ai): add RoleGoalie and zone constants"` + +### Задача 2: Реализация ИИ Вратаря (Перехватчик) + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Реализовать `updateGoalieAI`** +Логика: +1. Определить центр своих ворот (`GoalLeft` или `GoalRight`). +2. Вычислить вектор от центра ворот к шайбе. +3. Целевая точка = Центр ворот + (Нормализованный вектор * min(дистанция_до_шайбы, GoalieMaxDistance)). +4. Ограничить Y в пределах ворот (например, +/- 100 пикселей от центра). + +```go +func (w *World) updateGoalieAI(p *entities.Player, puckPos entities.Vector2D) entities.Vector2D { + goalPos := GoalLeft + if p.Team == TeamRight { + goalPos = GoalRight + } + + // Вектор от ворот к шайбе + dir := puckPos.Sub(goalPos) + dist := dir.Len() + + // Ограничиваем выход из ворот + moveDist := dist + if moveDist > GoalieMaxDistance { + moveDist = GoalieMaxDistance + } + + target := goalPos.Add(dir.Normalize().Mul(moveDist)) + + // Ограничение по Y (чтобы не уходил за пределы ворот) + if target.Y < WorldCenterY-100 { target.Y = WorldCenterY-100 } + if target.Y > WorldCenterY+100 { target.Y = WorldCenterY+100 } + + return target +} +``` + +- [ ] **Шаг 2: Интегрировать в `updatePlayerAI`** +```go +switch p.Role { +case entities.RoleGoalie: + target = w.updateGoalieAI(p, puckPos) +// ... остальные роли +} +``` + +- [ ] **Шаг 3: Коммит** +`git commit -m "feat(ai): implement goalie interceptor logic"` + +### Задача 3: Система зонирования для полевых игроков + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Реализовать определение зоны** +```go +func (w *World) getPlayerZone(p *entities.Player) int { + // 0: Защитная, 1: Средняя, 2: Атакующая + // Для TeamLeft: X < ZoneWidth (Защитная), X < 2*ZoneWidth (Средняя), else (Атакующая) + // Для TeamRight: X > 2*ZoneWidth (Защитная), X > ZoneWidth (Средняя), else (Атакующая) + if p.Team == TeamLeft { + if p.Pos.X < ZoneWidth { return 0 } + if p.Pos.X < 2*ZoneWidth { return 1 } + return 2 + } else { + if p.Pos.X > 2*ZoneWidth { return 0 } + if p.Pos.X > ZoneWidth { return 1 } + return 2 + } +} +``` + +- [ ] **Шаг 2: Обновить логику Striker и Defender с учетом зон** +- **Striker**: В зоне 0 (защитной) стремится к центру поля, в зоне 2 (атакующей) — агрессивно к шайбе. +- **Defender**: В зоне 0 — агрессивно к шайбе, в зоне 2 — возвращается к своим воротам. + +- [ ] **Шаг 3: Коммит** +`git commit -m "feat(ai): implement player zoning logic"` + +### Задача 4: Тестирование и верификация + +**Файлы:** +- Изменить: `internal/game/world_test.go` + +- [ ] **Шаг 1: Тест `TestWorld_GoaliePositioning`** +- Проверить, что вратарь TeamLeft при шайбе в центре поля стоит в районе `GoalLeft` и не уходит дальше `GoalieMaxDistance`. +- Проверить, что вратарь смещается вслед за шайбой по Y. + +- [ ] **Шаг 2: Тест `TestWorld_ZoningBehavior`** +- Проверить, что защитник в атакующей зоне стремится вернуться назад. + +- [ ] **Шаг 3: Запуск тестов** +`go test ./internal/game/ -v` + +- [ ] **Шаг 4: Коммит** +`git commit -m "test(ai): add goalie and zoning tests"` diff --git a/docs/plans/2026-05-12-match-logic-impl.md b/docs/plans/2026-05-12-match-logic-impl.md new file mode 100644 index 0000000..9946fad --- /dev/null +++ b/docs/plans/2026-05-12-match-logic-impl.md @@ -0,0 +1,102 @@ +# План: Game Rules & Match Logic (M5) + +**Цель:** Реализовать систему судейства, подсчёт голов, таймер матча и управление состояниями игры (Playing, GoalPause, MatchEnded). + +**Архитектура:** Введение `MatchManager` в качестве верхнего уровня управления. Он владеет `World` и контролирует, когда физика и ИИ мира должны обновляться, а когда игра должна быть заморожена. + +**Стек:** Go. + +**Спека:** [docs/specs/2026-05-12-match-logic-design.md](../specs/2026-05-12-match-logic-design.md) + +--- + +## Файловая структура + +- `internal/game/world.go` — добавление методов для проверки гола и сброса позиций. +- `internal/game/match_manager.go` — новый файл с логикой управления матчем, счетом и таймерами. +- `cmd/game/main.go` — замена `World` на `MatchManager` в основном цикле игры. + +--- + +## Задачи + +### Задача 1: Методы управления состоянием мира + +**Файлы:** +- Изменить: `internal/game/world.go` + +- [ ] **Шаг 1: Реализовать `CheckGoal()`** + - Метод должен проверять `Puck.Position.X`. + - Если `X < 0` $\rightarrow$ возвращает `(entities.TeamBlue, true)`. + - Если `X > WorldWidth` $\rightarrow$ возвращает `(entities.TeamRed, true)`. + - Иначе $\rightarrow$ `(entities.TeamRed, false)`. + +- [ ] **Шаг 2: Реализовать `ResetPositions()`** + - Установить `Puck.Position` в центр поля, `Puck.Velocity = 0`. + - Для каждого игрока в `Players` установить `Position = HomePosition` и `Velocity = 0`. + +- [ ] **Шаг 3: Коммит** + `git add internal/game/world.go && git commit -m "feat(game): add CheckGoal and ResetPositions to World"` + +### Задача 2: Реализация MatchManager + +**Файлы:** +- Создать: `internal/game/match_manager.go` + +- [ ] **Шаг 1: Определить типы и структуру** + - `GameState` (enum: `Playing`, `GoalPause`, `MatchEnded`). + - `MatchManager` структура: + - `World *World` + - `State GameState` + - `ScoreRed, ScoreBlue int` + - `MatchDuration float64` (в секундах) + - `CurrentTime float64` + - `PauseDuration float64` + - `PauseTimer float64` + +- [ ] **Шаг 2: Реализовать `Update(dt float64)`** + - Логика переключения состояний: + - Если `Playing`: + - Проверить `World.CheckGoal()`. Если гол $\rightarrow$ `State = GoalPause`, обновить счет, `PauseTimer = PauseDuration`. + - Если не гол $\rightarrow$ вызвать `World.Update(dt)`. + - Обновить `CurrentTime += dt`. Если `CurrentTime >= MatchDuration` $\rightarrow$ `State = MatchEnded`. + - Если `GoalPause`: + - `PauseTimer -= dt`. + - Если `PauseTimer <= 0` $\rightarrow$ вызвать `World.ResetPositions()`, `State = Playing`. + - Если `MatchEnded`: + - Ничего не делать. + +- [ ] **Шаг 3: Коммит** + `git add internal/game/match_manager.go && git commit -m "feat(game): implement MatchManager for game rules"` + +### Задача 3: Интеграция в Main Loop + +**Файлы:** +- Изменить: `cmd/game/main.go` + +- [ ] **Шаг 1: Обновить структуру `Game`** + - Заменить `world *game.World` на `matchManager *game.MatchManager`. + +- [ ] **Шаг 2: Обновить инициализацию в `NewGame()`** + - Создать `world := game.NewWorld()`. + - Создать `matchManager := game.NewMatchManager(world)`. + +- [ ] **Шаг 3: Обновить `Update()` и `Draw()`** + - В `Update()` вызывать `g.matchManager.Update(dt)`. + - В `Draw()` передавать `g.matchManager.World` в функцию отрисовки. + +- [ ] **Шаг 4: Коммит** + `git add cmd/game/main.go && git commit -m "feat(game): integrate MatchManager into main loop"` + +### Задача 4: Тестирование и верификация + +- [ ] **Шаг 1: Проверка сценария "Гол"** + - Запустить игру, забить гол. + - Ожидание: Игра замирает, через несколько секунд все возвращаются в центр. + +- [ ] **Шаг 2: Проверка сценария "Конец матча"** + - Установить короткий `MatchDuration` (например, 10 сек). + - Ожидание: Через 10 секунд игра замирает навсегда. + +- [ ] **Шаг 3: Коммит** + `git commit -m "test(game): verify match logic flow"` diff --git a/docs/reviews/2026-05-08-physics-dynamics.md b/docs/reviews/2026-05-08-physics-dynamics.md new file mode 100644 index 0000000..23a7d8b --- /dev/null +++ b/docs/reviews/2026-05-08-physics-dynamics.md @@ -0,0 +1,17 @@ +## Code review + +Объект: Physics & Puck Dynamics (M2) +Проверял: bugs, conventions, history, comments, tests. + +Найдено и исправлено: +1. **bug** — Boundary Jitter. + `internal/game/world.go` + Проблема: Отсутствие коррекции позиции при отскоке могло привести к застреванию шайбы в стене. + Решение: Добавлен clamping позиции к границам при обнаружении коллизии. + +2. **convention** — Float Precision in Tests. + `internal/game/world_test.go` + Проблема: Использование прямого сравнения `!=` для float64. + Решение: Переход на сравнение с использованием epsilon (`math.Abs`). + +Итог: Критические баги устранены, тесты проходят. diff --git a/docs/reviews/2026-05-09-ai-fixes.md b/docs/reviews/2026-05-09-ai-fixes.md new file mode 100644 index 0000000..edb89be --- /dev/null +++ b/docs/reviews/2026-05-09-ai-fixes.md @@ -0,0 +1,9 @@ +## Code review + +Объект: Fixes for Striker AI and magic numbers +Проверял: bugs, conventions, history, comments, tests. + +Проблем уровня ≥75 не нашёл. Все замечания предыдущего ревью исправлены: +- Логика Striker AI теперь корректно направляет игрока к шайбе в зоне противника. +- Магические числа 640/360 заменены на константы WorldCenterX/Y. +- Тесты в TestWorld_StrikerAI обновлены и проходят. diff --git a/docs/reviews/2026-05-09-ai-tests.md b/docs/reviews/2026-05-09-ai-tests.md new file mode 100644 index 0000000..c8c757d --- /dev/null +++ b/docs/reviews/2026-05-09-ai-tests.md @@ -0,0 +1,19 @@ +## Code review + +Объект: uncommitted changes (AI tests and Striker fix) +Найдено: 3 (после фильтра). + +1. **bug** — Striker AI logic is inverted (Regression). + `internal/game/world.go:134-136` + Доказательство: `if isOwnZone { return puckPos }` + Почему: Нападающий начинает преследовать шайбу только в своей половине поля, а в чужой остается статичным. Это противоположно логике футбола. + +2. **test** — Tests verify incorrect behavior. + `internal/game/world_test.go:88-112` + Доказательство: `TestWorld_StrikerAI` проверяет преследование шайбы в своей зоне. + Почему: Тест является «зеркалом» бага: он проходит, потому что подтверждает неправильное поведение. + +3. **convention** — Use of magic numbers for field dimensions. + `internal/game/world.go:133, 138, 141` + Доказательство: `puckPos.X < 640` + Почему: Значения 640 и 360 (центр поля) повторяются многократно. Следует вынести их в именованные константы (например, `WorldCenterX`). diff --git a/docs/specs/2026-05-08-hockey-design.md b/docs/specs/2026-05-08-hockey-design.md new file mode 100644 index 0000000..bae207b --- /dev/null +++ b/docs/specs/2026-05-08-hockey-design.md @@ -0,0 +1,102 @@ +# Hockey Game Design Specification + +## 1. Game Overview +A top-down autonomous hockey simulation where the user observes two teams playing. The game features 6v6 teams (1 goalie, 2 defenders, 3 forwards) plus 1 referee. The match duration is 10 minutes with goals, resets, and scoring. + +## 2. Technical Stack +- **Language**: Go +- **Graphics Engine**: Ebitengine (2D rendering) +- **Physics**: Simple 2D collision detection and response +- **AI**: Steering Behaviors combined with tactical zone management + +## 3. Game Rules & Setup +- **Team Composition**: 6v6 (1 goalie, 2 defenders, 3 forwards) per team, plus 1 referee +- **Match Duration**: 10 minutes +- **Objective**: Score more goals than the opponent +- **Reset**: After each goal, players and puck reset to starting positions +- **Scoring**: Goals scored when puck enters opponent's goal area + +## 4. Architecture + +### 4.1 Simulation Engine +- **World/Game State**: Manages all entities and game state +- **Physics**: Handles collisions, bounces, and movement +- **Game Timer**: Tracks match time and game state + +### 4.2 Entities +- **Puck**: Position, velocity, friction +- **Player**: Role, team, steering behavior +- **Referee**: Follows puck with larger arrival radius + +### 4.3 AI System +- **Zone Manager**: Defines defensive, middle, and attack zones +- **Steering Logic**: Weighted sum of vectors (Seek, Separation, Arrival, Avoidance) +- **Role-based Behaviors**: Specific AI for each player role + +### 4.4 Rendering +- Ebitengine-based 2D rendering of the rink, players, and puck + +## 5. File Structure +``` +cmd/game/main.go +internal/game/engine.go +internal/game/world.go +internal/game/timer.go +internal/entities/player.go +internal/entities/puck.go +internal/entities/referee.go +internal/ai/steering.go +internal/ai/zones.go +internal/ai/brain.go +internal/physics/collisions.go +internal/render/renderer.go +``` + +## 6. Detailed Logic + +### 6.1 Game Loop +1. AI Update +2. Physics Update +3. Game State Update (goals, timer) +4. Render + +### 6.2 Steering Behaviors +- **Seek**: Calculate vector towards target +- **Separation**: Avoid nearby teammates +- **Arrival**: Slow down as target is reached +- **Avoidance**: Prevent sticking to boards +- **Weighted Sum**: Combine behaviors with tactical zone weights + +### 6.3 Role-based AI + +#### Forwards +- Aggressive Seek(Puck) in attack zone +- Return to home zone if puck is far +- Use Support behavior to create passing options + +#### Defenders +- High Separation(Opponents) +- Seek(Puck) in defense/middle zones +- Use Support behavior to coordinate attacks and transitions + +#### Goalie +- Restricted to goal area +- Seek(Puck) primarily on X-axis +- Maintain position in goal area + +#### Referee +- Gentle Seek(Puck) to stay nearby +- Larger arrival radius than players + +### 6.4 Puck Interaction +- **Possession**: When within radius R +- **Shooting**: Triggered by probability or position +- **Passing**: A player with the puck can pass to a teammate if they are in a favorable position (closer to the opponent's goal and not heavily blocked). A pass is a high-velocity impulse towards the teammate. +- **Control**: Radius-based capture and release +- **Friction**: Gradual velocity reduction over time + +## 7. Physics +- **Collisions**: Simple 2D elastic collisions (player-player, player-puck, puck-boards) +- **Bounces**: Puck bounces off walls with energy loss +- **Friction**: Puck gradually slows down over time +- **Movement**: Velocity-based position updates \ No newline at end of file diff --git a/docs/specs/2026-05-08-physics-dynamics-design.md b/docs/specs/2026-05-08-physics-dynamics-design.md new file mode 100644 index 0000000..f8daa9f --- /dev/null +++ b/docs/specs/2026-05-08-physics-dynamics-design.md @@ -0,0 +1,31 @@ +# Design: Physics & Puck Dynamics (M2) + +## Goal +Implement basic physical properties for the puck to make movement feel natural: gradual slowing down (friction) and energy loss upon hitting boundaries (restitution). + +## Architecture +The physics logic will be integrated into the `World.Update` loop. Constants for physical properties will be defined to allow easy tuning of the game feel. + +## Implementation Details + +### 1. Physical Constants +The following constants will be introduced: +- `PuckFriction`: Multiplier applied to velocity every frame (e.g., `0.99`). +- `PuckRestitution`: Multiplier applied to velocity upon boundary collision (e.g., `0.8`). +- `StopThreshold`: Minimum velocity magnitude below which the puck is forced to a complete stop (e.g., `0.1`). + +### 2. Movement Logic (World.Update) +The update loop for the puck will follow these steps: +1. **Apply Friction**: `velocity = velocity * PuckFriction`. +2. **Stop Check**: If `length(velocity) < StopThreshold`, then `velocity = (0, 0)`. +3. **Position Update**: `position = position + velocity`. +4. **Boundary Collision**: + - If the puck hits a boundary (considering its radius): + - Invert the velocity component perpendicular to the boundary. + - Multiply the resulting velocity by `PuckRestitution`. + - **Correction**: Snap the puck's position to be exactly on the boundary edge to prevent it from getting stuck inside the wall. + +## Success Criteria +- The puck gradually slows down and eventually stops. +- The puck loses speed after bouncing off the walls. +- The puck does not get stuck in the boundaries. diff --git a/docs/specs/2026-05-08-player-ai-design.md b/docs/specs/2026-05-08-player-ai-design.md new file mode 100644 index 0000000..3ee8e85 --- /dev/null +++ b/docs/specs/2026-05-08-player-ai-design.md @@ -0,0 +1,49 @@ +# Design: Player AI and Steering System + +## Goal +Implement autonomous players that move organically on the ice, reacting to the puck and each other, with randomized individual characteristics. + +## Architecture +The system is based on "Steering Behaviors", where players calculate a desired velocity based on multiple forces, which are then summed and applied to the player's physics. + +### 1. Player Attributes +Each player is assigned a set of attributes that define their physical and mental capabilities. +- **Budget**: 10 points total. +- **Distribution**: Each attribute gets a minimum of 1 point; the remaining 6 points are distributed randomly. +- **Attributes**: + - **Speed**: Affects `MaxSpeed`. Higher speed allows faster movement. + - **Agility**: Affects `MaxForce`. Higher agility allows sharper turns and faster acceleration. + - **Strength**: Affects collision impulse. Stronger players push the puck and opponents further. + - **Tactics**: Affects the weight of steering behaviors (e.g., how effectively they seek the puck). + +### 2. Steering Behaviors +The `internal/game/steering.go` module will provide the following forces: +- **Seek(target)**: Direct force towards the target (puck). +- **Arrive(target)**: Similar to Seek, but slows down as the player reaches the target to prevent orbiting. +- **Avoidance(others)**: A strong repulsive force to prevent players from overlapping. +- **ZoneConstraint(homeZone)**: A soft force pulling the player back to their half of the ice if they stray too far. + +### 3. File Structure +- `internal/entities/player.go`: Defines `Player` and `Attributes` structs and the `NewPlayer` constructor with random attribute logic. +- `internal/game/steering.go`: Pure functions for calculating steering vectors. +- `internal/game/world.go`: + - Manages the list of players. + - Updates player positions using steering forces. + - Handles collisions (Player-Puck, Player-Player) using the `Strength` attribute. +- `internal/render/renderer.go`: Renders players as colored circles (Red/Blue), with size slightly scaled by `Strength`. + +## Data Flow +1. **Per-Tick Update**: + - Calculate Steering Forces: `TotalForce = (Seek * Tactics) + Avoidance + ZoneConstraint`. + - Apply Physics: `Acceleration = TotalForce / Mass` (capped by `Agility`). + - Update Velocity: `Velocity += Acceleration` (capped by `Speed`). + - Update Position: `Position += Velocity`. +2. **Collision Resolution**: + - If Player touches Puck: Transfer momentum based on `Strength` and `Velocity`. + - If Player touches Player: Repel based on relative `Strength`. +3. **Rendering**: Draw players at their current positions. + +## Edge Cases +- **Overlapping**: High-priority `Avoidance` force prevents players from stacking. +- **Boundary Control**: Players are clamped to the ice rink boundaries. +- **Damping**: A small friction coefficient is applied to velocity to prevent infinite sliding. diff --git a/docs/specs/2026-05-09-players-ai-design.md b/docs/specs/2026-05-09-players-ai-design.md new file mode 100644 index 0000000..d15cc56 --- /dev/null +++ b/docs/specs/2026-05-09-players-ai-design.md @@ -0,0 +1,55 @@ +# Design: Players & AI Behavior (M3) + +## Goal +Implement autonomous players with role-based and zone-based AI that move realistically and maintain tactical positions without user input. + +## Architecture +The system separates player data from behavior logic. + +### 1. Components +- **`Player` Entity**: A data structure containing physical properties (position, velocity, radius), team identity (Red/Blue), role (Striker/Defender), and movement constraints (MaxSpeed, Acceleration). +- **`PlayerAI` Logic**: A set of rules that determine the "Target Point" based on the puck's position and the player's role. +- **`World` Integration**: The world manages a collection of players, updates their AI state, applies steering physics, and handles boundary clamping. + +## Detailed Design + +### 1. Player Roles & Behavior +Players operate based on a hybrid of roles and zones. + +#### Defender +- **Home Zone**: Their own half of the field. +- **Behavior**: + - **Puck in opponent's half**: Return to `HomePosition` (positioned in front of their own goal). + - **Puck in their half**: Move to a point on the line between the `Puck` and `Their Goal` to block the path. + +#### Striker +- **Home Zone**: Center of the field and opponent's half. +- **Behavior**: + - **Puck in their zone**: Actively chase the `Puck`. + - **Puck deep in their own half**: Maintain a position near the center or between the center and the opponent's goal, avoiding overcrowding the defender's zone. + +### 2. Movement Physics (Steering) +To avoid "robotic" movement, players use a steering-like approach: +1. **Target Point**: AI determines where the player *wants* to be. +2. **Desired Velocity**: A vector from current position to target, normalized and scaled by `MaxSpeed`. +3. **Acceleration**: The difference between `Desired Velocity` and `Current Velocity` is applied as a force, capped by the `Acceleration` parameter. +4. **Integration**: `Position += Velocity`. + +### 3. File Structure +- `internal/entities/player.go`: Defines `Team`, `Role`, and `Player` struct. +- `internal/game/world.go`: + - Adds `Players []*Player` to `World`. + - Defines goal coordinates (`GoalLeft`, `GoalRight`). + - Implements `updatePlayersAI()` and integrates it into `World.Update`. +- `internal/game/world_test.go`: Tests for AI target selection and movement. + +### 4. Edge Cases & Polish +- **Anti-Clumping**: Small repulsive force between teammates to prevent them from overlapping perfectly. +- **Dead Zone**: A small radius around the target point where the player stops moving to prevent jittering. +- **Boundary Clamping**: Players are clamped to the field boundaries to prevent them from leaving the screen. + +## Success Criteria +- Players are rendered on screen with distinct colors. +- Defenders stay back when the puck is far and block the goal when it's near. +- Strikers chase the puck but don't crowd their own defenders. +- Movement is smooth (accelerating/decelerating) rather than instant. diff --git a/docs/specs/2026-05-09-tactical-ai-design.md b/docs/specs/2026-05-09-tactical-ai-design.md new file mode 100644 index 0000000..d845f01 --- /dev/null +++ b/docs/specs/2026-05-09-tactical-ai-design.md @@ -0,0 +1,44 @@ +# Design: Tactical AI and Goalie (M4) + +## Goal +Implement a "smart" goalie that covers angles and a zone-based tactical system for players to make the game feel more professional and less like a simple "chase the puck" simulation. + +## Architecture +The system extends the existing steering-based movement. Instead of a simple target, the target is now determined by a combination of the player's role and the current game zone. + +### 1. Goalie AI (The Interceptor) +The goalie's primary objective is to block the path between the puck and the goal center. +- **Anchor Point**: The center of the goalie's own goal. +- **Positioning Logic**: + - Calculate the vector from the goal center to the puck. + - The goalie attempts to stay on this vector, effectively "cutting off the angle". + - **Constraint**: The goalie is restricted to a small semi-circle (radius ~100-150px) around the goal center to prevent them from wandering into the midfield. + - **Idle State**: When the puck is far away, the goalie returns to the center of the goal. +- **Movement**: Uses the existing steering physics for smooth transitions. + +### 2. Zone-Based Tactics +The field is divided into three zones for each team: **Defensive**, **Middle**, and **Offensive**. + +| Role | Defensive Zone | Middle Zone | Offensive Zone | +| :--- | :--- | :--- | :--- | +| **Striker** | Return to Middle/Offensive | Support attack | Aggressively pursue puck | +| **Defender** | Aggressively pursue puck | Maintain position/Support | Stay back (Insurance) | +| **Goalie** | Intercept puck | Stay in goal area | Stay in goal area | + +### 3. Technical Details +- **Constants**: Define `WorldZoneWidth` (1/3 of field width) and `GoalieRadius` to avoid magic numbers. +- **Role Update**: Add `RoleGoalie` to `PlayerRole` enum. +- **Logic Flow**: `Puck Position` $\rightarrow$ `Zone Detection` $\rightarrow$ `Role-based Target Calculation` $\rightarrow$ `Steering` $\rightarrow$ `Movement`. + +## File Changes +- `internal/entities/player.go`: Add `RoleGoalie`. +- `internal/game/world.go`: + - Add zone and goal constants. + - Implement `updateGoalieAI`. + - Refactor `updatePlayerAI` to use zone-based logic. +- `internal/game/world_test.go`: Add tests for goalie positioning and zone transitions. + +## Edge Cases +- **Puck behind goal**: Goalie resets to center. +- **Crowding**: Existing anti-clumping logic prevents players from stacking on top of the goalie. +- **Jitter**: Implement a small distance threshold before updating the target to prevent micro-oscillations. diff --git a/docs/specs/2026-05-12-match-logic-design.md b/docs/specs/2026-05-12-match-logic-design.md new file mode 100644 index 0000000..5e5c092 --- /dev/null +++ b/docs/specs/2026-05-12-match-logic-design.md @@ -0,0 +1,75 @@ +# Design: Match Logic & Game Rules (M5) + +**Goal:** Implement a high-level match management system to handle scoring, game timing, and state transitions (Playing, Goal Pause, Match Ended). + +**Architecture:** +Introduction of a `MatchManager` component that acts as a controller over the `World`. The `World` remains responsible for physics and AI, while the `MatchManager` handles the "rules of the sport". + +--- + +## 1. Components + +### MatchManager +The central authority for the match state. +- **GameState**: `Playing`, `GoalPause`, `MatchEnded`. +- **Score**: `ScoreRed`, `ScoreBlue` (integers). +- **Match Timer**: `MatchDuration` (total time) and `CurrentTime` (elapsed). +- **Pause Timer**: `PauseDuration` (fixed duration for goal celebration) and `PauseTimer` (countdown). +- **Reference**: Pointer to `World`. + +### World (Extensions) +- **`CheckGoal() (team entities.Team, scored bool)`**: Checks if the puck has crossed the goal lines (X < 0 or X > WorldWidth). +- **`ResetPositions()`**: Resets the puck to the center and all players to their `HomePosition`, zeroing out all velocities. + +--- + +## 2. File Structure + +- **`internal/game/match_manager.go` (New)**: + - Definition of `GameState` and `MatchManager` struct. + - `Update()` method to drive the match lifecycle. +- **`internal/game/world.go` (Modified)**: + - Implementation of `CheckGoal()` and `ResetPositions()`. +- **`cmd/game/main.go` (Modified)**: + - Replace `World` with `MatchManager` in the `Game` struct. + - Update `Update()` and `Draw()` calls to go through `MatchManager`. + +--- + +## 3. Data Flow + +### Main Loop $\rightarrow$ MatchManager +`Game.Update()` $\rightarrow$ `MatchManager.Update()`: +1. **If `Playing`**: + - Call `World.CheckGoal()`. + - If goal $\rightarrow$ transition to `GoalPause`, increment score, start `PauseTimer`. + - If no goal $\rightarrow$ call `World.Update()` (physics/AI) and increment `CurrentTime`. + - If `CurrentTime >= MatchDuration` $\rightarrow$ transition to `MatchEnded`. +2. **If `GoalPause`**: + - Decrement `PauseTimer`. + - If `PauseTimer <= 0` $\rightarrow$ call `World.ResetPositions()` and transition to `Playing`. + - `World.Update()` is skipped (game is frozen). +3. **If `MatchEnded`**: + - `World.Update()` is skipped. + +### World $\rightarrow$ MatchManager (Goal Detection) +- `X < 0` $\rightarrow$ `(TeamBlue, true)` +- `X > WorldWidth` $\rightarrow$ `(TeamRed, true)` +- Otherwise $\rightarrow$ `(_, false)` + +--- + +## 4. Edge Cases & Handling + +- **Double Scoring**: The `GoalPause` state prevents `CheckGoal` from being called repeatedly until the world is reset. +- **Reset Collisions**: `ResetPositions` explicitly sets `Velocity = 0` for all entities to prevent immediate "explosions" from anti-clumping logic. +- **Boundary Stuck**: `CheckGoal` is evaluated before physics clamping to ensure goals are registered even at high speeds. +- **Last-second Goal**: Goals are processed before the match timer check, ensuring a goal on the final frame is counted. + +--- + +## 5. Success Criteria +- Puck crossing the boundary increments the correct team's score. +- The game freezes for a few seconds after a goal. +- All entities return to starting positions after the pause. +- The match stops automatically when the timer reaches the limit. diff --git a/game b/game new file mode 100755 index 0000000..7dd4e20 Binary files /dev/null and b/game differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..99febef --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/soer/football + +go 1.24.0 + +require github.com/hajimehoshi/ebiten/v2 v2.9.9 + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..03c113f --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M= +github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/entities/player.go b/internal/entities/player.go new file mode 100644 index 0000000..a76768c --- /dev/null +++ b/internal/entities/player.go @@ -0,0 +1,40 @@ +package entities + +type Team int + +const ( + TeamRed Team = iota + TeamBlue +) + +type Role int + +const ( + RoleStriker Role = iota + RoleDefender + RoleGoalie +) + +type Player struct { + Position Vector2 + Velocity Vector2 + Radius float64 + Team Team + Role Role + HomePosition Vector2 + MaxSpeed float64 + Acceleration float64 +} + +func NewPlayer(x, y float64, team Team, role Role) *Player { + return &Player{ + Position: Vector2{X: x, Y: y}, + HomePosition: Vector2{X: x, Y: y}, + Velocity: Vector2{X: 0, Y: 0}, + Radius: 15, + Team: team, + Role: role, + MaxSpeed: 3.0, + Acceleration: 0.1, + } +} diff --git a/internal/entities/puck.go b/internal/entities/puck.go new file mode 100644 index 0000000..dee5fec --- /dev/null +++ b/internal/entities/puck.go @@ -0,0 +1,19 @@ +package entities + +type Vector2 struct { + X, Y float64 +} + +type Puck struct { + Position Vector2 + Velocity Vector2 + Radius float64 +} + +func NewPuck(x, y float64) *Puck { + return &Puck{ + Position: Vector2{X: x, Y: y}, + Velocity: Vector2{X: 0, Y: 0}, + Radius: 10, + } +} diff --git a/internal/game/world.go b/internal/game/world.go new file mode 100644 index 0000000..eba1b13 --- /dev/null +++ b/internal/game/world.go @@ -0,0 +1,258 @@ +package game + +import ( + "math" + + "github.com/soer/football/internal/entities" +) + +const ( + Friction = 0.99 + Restitution = 0.8 + StopThreshold = 0.1 + WorldWidth = 1280.0 + WorldHeight = 720.0 + WorldCenterX = 640.0 + WorldCenterY = 360.0 + ZoneWidth = WorldWidth / 3 + GoalieMaxDistance = 80.0 + GoalieDeadzone = 1.0 +) + +var ( + GoalLeft = entities.Vector2{X: 50, Y: WorldCenterY} + GoalRight = entities.Vector2{X: 1230, Y: WorldCenterY} +) + +type World struct { + Puck *entities.Puck + Players []*entities.Player + Width float64 + Height float64 +} + +func NewWorld() *World { + return &World{ + Puck: entities.NewPuck(WorldCenterX, WorldCenterY), + Width: WorldWidth, + Height: WorldHeight, + Players: []*entities.Player{ + entities.NewPlayer(200, WorldCenterY, entities.TeamRed, entities.RoleDefender), + entities.NewPlayer(400, WorldCenterY, entities.TeamRed, entities.RoleStriker), + entities.NewPlayer(GoalLeft.X, GoalLeft.Y, entities.TeamRed, entities.RoleGoalie), + entities.NewPlayer(1080, WorldCenterY, entities.TeamBlue, entities.RoleDefender), + entities.NewPlayer(880, WorldCenterY, entities.TeamBlue, entities.RoleStriker), + entities.NewPlayer(GoalRight.X, GoalRight.Y, entities.TeamBlue, entities.RoleGoalie), + }, + } +} + +func (w *World) CheckGoal() (entities.Team, bool) { + if w.Puck.Position.X < 0 { + return entities.TeamBlue, true + } + if w.Puck.Position.X > w.Width { + return entities.TeamRed, true + } + return entities.TeamRed, false +} + +func (w *World) ResetPositions() { + w.Puck.Position = entities.Vector2{X: WorldCenterX, Y: WorldCenterY} + w.Puck.Velocity = entities.Vector2{X: 0, Y: 0} + for _, p := range w.Players { + p.Position = p.HomePosition + p.Velocity = entities.Vector2{X: 0, Y: 0} + } +} + +func (w *World) Update() { + // 1. Update Puck + w.Puck.Velocity.X *= Friction + w.Puck.Velocity.Y *= Friction + + if (w.Puck.Velocity.X*w.Puck.Velocity.X + w.Puck.Velocity.Y*w.Puck.Velocity.Y) < StopThreshold*StopThreshold { + w.Puck.Velocity.X = 0 + w.Puck.Velocity.Y = 0 + } + + w.Puck.Position.X += w.Puck.Velocity.X + w.Puck.Position.Y += w.Puck.Velocity.Y + + if w.Puck.Position.X < w.Puck.Radius { + w.Puck.Position.X = w.Puck.Radius + w.Puck.Velocity.X *= -Restitution + } else if w.Puck.Position.X > w.Width-w.Puck.Radius { + w.Puck.Position.X = w.Width - w.Puck.Radius + w.Puck.Velocity.X *= -Restitution + } + + if w.Puck.Position.Y < w.Puck.Radius { + w.Puck.Position.Y = w.Puck.Radius + w.Puck.Velocity.Y *= -Restitution + } else if w.Puck.Position.Y > w.Height-w.Puck.Radius { + w.Puck.Position.Y = w.Height - w.Puck.Radius + w.Puck.Velocity.Y *= -Restitution + } + + // 2. Update Players + for _, p := range w.Players { + // AI Target + target := w.calculateTarget(p, w.Puck.Position) + w.applySteering(p, target) + + // Anti-clumping (repulsion from teammates) + for _, other := range w.Players { + if p == other || p.Team != other.Team { + continue + } + dx := p.Position.X - other.Position.X + dy := p.Position.Y - other.Position.Y + distSq := dx*dx + dy*dy + if distSq < 40*40 && distSq > 0.01 { + dist := math.Sqrt(distSq) + p.Velocity.X += (dx / dist) * 0.1 + p.Velocity.Y += (dy / dist) * 0.1 + } + } + + // Apply movement + p.Position.X += p.Velocity.X + p.Position.Y += p.Velocity.Y + + // Boundary clamping + if p.Position.X < 0 { + p.Position.X = 0 + } else if p.Position.X > w.Width { + p.Position.X = w.Width + } + if p.Position.Y < 0 { + p.Position.Y = 0 + } else if p.Position.Y > w.Height { + p.Position.Y = w.Height + } + } +} + +func (w *World) applySteering(p *entities.Player, target entities.Vector2) { + dx := target.X - p.Position.X + dy := target.Y - p.Position.Y + dist := math.Sqrt(dx*dx + dy*dy) + + // Dead zone + if dist < 1.0 { + p.Velocity.X = 0 + p.Velocity.Y = 0 + return + } + + // Desired velocity + desiredX := (dx / dist) * p.MaxSpeed + desiredY := (dy / dist) * p.MaxSpeed + + // Steering force + steeringX := desiredX - p.Velocity.X + steeringY := desiredY - p.Velocity.Y + steeringDist := math.Sqrt(steeringX*steeringX + steeringY*steeringY) + + if steeringDist > p.Acceleration { + steeringX = (steeringX / steeringDist) * p.Acceleration + steeringY = (steeringY / steeringDist) * p.Acceleration + } + + p.Velocity.X += steeringX + p.Velocity.Y += steeringY + + // Cap final velocity + speed := math.Sqrt(p.Velocity.X*p.Velocity.X + p.Velocity.Y*p.Velocity.Y) + if speed > p.MaxSpeed { + p.Velocity.X = (p.Velocity.X / speed) * p.MaxSpeed + p.Velocity.Y = (p.Velocity.Y / speed) * p.MaxSpeed + } +} + +func (w *World) getPuckZone(team entities.Team, puckPos entities.Vector2) int { + // 0: Defensive, 1: Middle, 2: Offensive + if team == entities.TeamRed { + if puckPos.X < ZoneWidth { + return 0 + } + if puckPos.X < 2*ZoneWidth { + return 1 + } + return 2 + } else { + if puckPos.X > WorldWidth-ZoneWidth { + return 0 + } + if puckPos.X > WorldWidth-2*ZoneWidth { + return 1 + } + return 2 + } +} + +func (w *World) calculateTarget(p *entities.Player, puckPos entities.Vector2) entities.Vector2 { + zone := w.getPuckZone(p.Team, puckPos) + + goalPos := GoalLeft + if p.Team == entities.TeamBlue { + goalPos = GoalRight + } + + switch p.Role { + case entities.RoleDefender: + switch zone { + case 0: // Defensive: Intercept puck + return entities.Vector2{ + X: (puckPos.X + goalPos.X) / 2, + Y: (puckPos.Y + goalPos.Y) / 2, + } + case 1: // Middle: Move towards puck but stay slightly behind it + return entities.Vector2{ + X: puckPos.X + (goalPos.X-puckPos.X)*0.2, + Y: puckPos.Y + (goalPos.Y-puckPos.Y)*0.2, + } + case 2: // Offensive: Return to Middle Zone + return p.HomePosition + } + case entities.RoleStriker: + switch zone { + case 2: // Offensive: Aggressively pursue puck + return puckPos + case 1: // Middle: Pursue puck + return puckPos + case 0: // Defensive: Return to Middle Zone + return entities.Vector2{X: WorldCenterX, Y: WorldCenterY} + } + case entities.RoleGoalie: + return w.updateGoalieAI(p, puckPos) + } + return p.HomePosition +} + +func (w *World) updateGoalieAI(p *entities.Player, puckPos entities.Vector2) entities.Vector2 { + goalCenter := GoalLeft + if p.Team == entities.TeamBlue { + goalCenter = GoalRight + } + + // Check if puck is behind the goal + if (p.Team == entities.TeamRed && puckPos.X < goalCenter.X) || (p.Team == entities.TeamBlue && puckPos.X > goalCenter.X) { + return goalCenter + } + + dx := puckPos.X - goalCenter.X + dy := puckPos.Y - goalCenter.Y + dist := math.Sqrt(dx*dx + dy*dy) + + if dist < GoalieDeadzone { + return goalCenter + } + + moveDist := math.Min(dist, GoalieMaxDistance) + return entities.Vector2{ + X: goalCenter.X + (dx/dist)*moveDist, + Y: goalCenter.Y + (dy/dist)*moveDist, + } +} diff --git a/internal/game/world_test.go b/internal/game/world_test.go new file mode 100644 index 0000000..d7f054c --- /dev/null +++ b/internal/game/world_test.go @@ -0,0 +1,233 @@ +package game + +import ( + "math" + "testing" + "github.com/soer/football/internal/entities" +) + +const epsilon = 1e-9 + +func TestWorld_Friction(t *testing.T) { + w := NewWorld() + + w.Puck.Velocity = entities.Vector2{X: 10, Y: 0} + + w.Update() + + expectedX := 10.0 * Friction + if math.Abs(w.Puck.Velocity.X-expectedX) > epsilon { + t.Errorf("Expected velocity X to be %f, got %f", expectedX, w.Puck.Velocity.X) + } +} + +func TestWorld_StopThreshold(t *testing.T) { + w := NewWorld() + // Set velocity just below StopThreshold + w.Puck.Velocity = entities.Vector2{X: StopThreshold * 0.5, Y: 0} + + w.Update() + + if math.Abs(w.Puck.Velocity.X) > epsilon || math.Abs(w.Puck.Velocity.Y) > epsilon { + t.Errorf("Expected velocity to be 0, got {%f, %f}", w.Puck.Velocity.X, w.Puck.Velocity.Y) + } +} + +func TestWorld_Restitution(t *testing.T) { + w := NewWorld() + + w.Puck.Position = entities.Vector2{X: 8, Y: 360} + w.Puck.Velocity = entities.Vector2{X: -2, Y: 0} + + w.Update() + + expectedX := (-2.0 * Friction) * -Restitution + if math.Abs(w.Puck.Velocity.X-expectedX) > epsilon { + t.Errorf("Expected velocity X to be %f, got %f", expectedX, w.Puck.Velocity.X) + } +} + +func TestWorld_ZoningAI(t *testing.T) { + w := NewWorld() + + // TeamRed Defender + redDef := &entities.Player{ + Position: entities.Vector2{X: 200, Y: 360}, + HomePosition: entities.Vector2{X: 200, Y: 360}, + Team: entities.TeamRed, + Role: entities.RoleDefender, + } + + // TeamRed Striker + redStr := &entities.Player{ + Position: entities.Vector2{X: 400, Y: 360}, + HomePosition: entities.Vector2{X: 400, Y: 360}, + Team: entities.TeamRed, + Role: entities.RoleStriker, + } + + // Case 1: Puck in Defensive Zone (Zone 0) + puckPos0 := entities.Vector2{X: 100, Y: 360} + + // Defender should intercept + targetDef0 := w.calculateTarget(redDef, puckPos0) + expectedDef0 := entities.Vector2{X: (100.0 + GoalLeft.X) / 2, Y: 360} + if targetDef0 != expectedDef0 { + t.Errorf("Zone 0: Expected Red Defender to target %v, got %v", expectedDef0, targetDef0) + } + + // Striker should return to center + targetStr0 := w.calculateTarget(redStr, puckPos0) + expectedStr0 := entities.Vector2{X: WorldCenterX, Y: WorldCenterY} + if targetStr0 != expectedStr0 { + t.Errorf("Zone 0: Expected Red Striker to target %v, got %v", expectedStr0, targetStr0) + } + + // Case 2: Puck in Middle Zone (Zone 1) + puckPos1 := entities.Vector2{X: 600, Y: 360} + + // Defender should stay slightly behind + targetDef1 := w.calculateTarget(redDef, puckPos1) + expectedDef1 := entities.Vector2{X: 600 + (GoalLeft.X-600)*0.2, Y: 360} + if targetDef1 != expectedDef1 { + t.Errorf("Zone 1: Expected Red Defender to target %v, got %v", expectedDef1, targetDef1) + } + + // Striker should pursue + targetStr1 := w.calculateTarget(redStr, puckPos1) + if targetStr1 != puckPos1 { + t.Errorf("Zone 1: Expected Red Striker to target puck %v, got %v", puckPos1, targetStr1) + } + + // Case 3: Puck in Offensive Zone (Zone 2) + puckPos2 := entities.Vector2{X: 1000, Y: 360} + + // Defender should return home + targetDef2 := w.calculateTarget(redDef, puckPos2) + if targetDef2 != redDef.HomePosition { + t.Errorf("Zone 2: Expected Red Defender to target HomePosition %v, got %v", redDef.HomePosition, targetDef2) + } + + // Striker should aggressively pursue + targetStr2 := w.calculateTarget(redStr, puckPos2) + if targetStr2 != puckPos2 { + t.Errorf("Zone 2: Expected Red Striker to target puck %v, got %v", puckPos2, targetStr2) + } +} + +func TestWorld_GoalieAI(t *testing.T) { + w := NewWorld() + + // Create a Red Goalie + redGoalie := &entities.Player{ + Position: entities.Vector2{X: GoalLeft.X, Y: GoalLeft.Y}, + HomePosition: entities.Vector2{X: GoalLeft.X, Y: GoalLeft.Y}, + Team: entities.TeamRed, + Role: entities.RoleGoalie, + } + + // Case 1: Puck is far away. Target should be at GoalieMaxDistance from goal. + w.Puck.Position = entities.Vector2{X: 500, Y: 360} + target := w.calculateTarget(redGoalie, w.Puck.Position) + + dx := target.X - GoalLeft.X + dy := target.Y - GoalLeft.Y + dist := math.Sqrt(dx*dx + dy*dy) + if math.Abs(dist-GoalieMaxDistance) > epsilon { + t.Errorf("Expected Red Goalie to target distance %f, got %f", GoalieMaxDistance, dist) + } + + // Case 2: Puck is close. Target should be exactly at puck position. + w.Puck.Position = entities.Vector2{X: GoalLeft.X + 10, Y: GoalLeft.Y + 10} + target = w.calculateTarget(redGoalie, w.Puck.Position) + if target != w.Puck.Position { + t.Errorf("Expected Red Goalie to target puck position when close, got %v", target) + } + + // Case 3: Puck is behind the goal. Target should be goal center. + w.Puck.Position = entities.Vector2{X: GoalLeft.X - 10, Y: GoalLeft.Y} + target = w.calculateTarget(redGoalie, w.Puck.Position) + if target != GoalLeft { + t.Errorf("Expected Red Goalie to target goal center when puck is behind, got %v", target) + } + + // Case 4: Puck is within GoalieDeadzone. Target should be goal center. + w.Puck.Position = entities.Vector2{X: GoalLeft.X + GoalieDeadzone*0.5, Y: GoalLeft.Y} + target = w.calculateTarget(redGoalie, w.Puck.Position) + if target != GoalLeft { + t.Errorf("Expected Red Goalie to target goal center when puck is within deadzone, got %v", target) + } + + // Case 5: Blue Goalie + blueGoalie := &entities.Player{ + Position: entities.Vector2{X: GoalRight.X, Y: GoalRight.Y}, + HomePosition: entities.Vector2{X: GoalRight.X, Y: GoalRight.Y}, + Team: entities.TeamBlue, + Role: entities.RoleGoalie, + } + w.Puck.Position = entities.Vector2{X: 700, Y: 360} + target = w.calculateTarget(blueGoalie, w.Puck.Position) + dx = target.X - GoalRight.X + dy = target.Y - GoalRight.Y + dist = math.Sqrt(dx*dx + dy*dy) + if math.Abs(dist-GoalieMaxDistance) > epsilon { + t.Errorf("Expected Blue Goalie to target distance %f, got %f", GoalieMaxDistance, dist) + } +} + +func TestWorld_CheckGoal(t *testing.T) { + w := NewWorld() + + // Case 1: Puck goes off left side (Blue scores) + w.Puck.Position = entities.Vector2{X: -1, Y: 360} + team, scored := w.CheckGoal() + if !scored || team != entities.TeamBlue { + t.Errorf("Expected TeamBlue to score, got %v, %v", team, scored) + } + + // Case 2: Puck goes off right side (Red scores) + w.Puck.Position = entities.Vector2{X: WorldWidth + 1, Y: 360} + team, scored = w.CheckGoal() + if !scored || team != entities.TeamRed { + t.Errorf("Expected TeamRed to score, got %v, %v", team, scored) + } + + // Case 3: Puck is in bounds + w.Puck.Position = entities.Vector2{X: WorldCenterX, Y: WorldCenterY} + team, scored = w.CheckGoal() + if scored { + t.Errorf("Expected no goal, but scored: %v", team) + } +} + +func TestWorld_ResetPositions(t *testing.T) { + w := NewWorld() + + // Move puck and players away from home + w.Puck.Position = entities.Vector2{X: 100, Y: 100} + w.Puck.Velocity = entities.Vector2{X: 5, Y: 5} + for _, p := range w.Players { + p.Position = entities.Vector2{X: 500, Y: 500} + p.Velocity = entities.Vector2{X: 2, Y: 2} + } + + w.ResetPositions() + + // Verify puck + if w.Puck.Position != (entities.Vector2{X: WorldCenterX, Y: WorldCenterY}) { + t.Errorf("Expected puck at center, got %v", w.Puck.Position) + } + if w.Puck.Velocity != (entities.Vector2{X: 0, Y: 0}) { + t.Errorf("Expected puck velocity 0, got %v", w.Puck.Velocity) + } + + // Verify players + for _, p := range w.Players { + if p.Position != p.HomePosition { + t.Errorf("Expected player at home position %v, got %v", p.HomePosition, p.Position) + } + if p.Velocity != (entities.Vector2{X: 0, Y: 0}) { + t.Errorf("Expected player velocity 0, got %v", p.Velocity) + } + } +} diff --git a/internal/render/renderer.go b/internal/render/renderer.go new file mode 100644 index 0000000..22b9f05 --- /dev/null +++ b/internal/render/renderer.go @@ -0,0 +1,47 @@ +package render + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + "github.com/soer/football/internal/entities" + "github.com/soer/football/internal/game" +) + +type Renderer struct{} + +func NewRenderer() *Renderer { + return &Renderer{} +} + +func (r *Renderer) DrawWorld(screen *ebiten.Image, world *game.World) { + // Draw Puck + vector.DrawFilledCircle( + screen, + float32(world.Puck.Position.X), + float32(world.Puck.Position.Y), + float32(world.Puck.Radius), + color.White, + true, + ) + + // Draw Players + for _, p := range world.Players { + var c color.Color + if p.Team == entities.TeamRed { + c = color.RGBA{R: 255, G: 0, B: 0, A: 255} + } else { + c = color.RGBA{R: 0, G: 0, B: 255, A: 255} + } + + vector.DrawFilledCircle( + screen, + float32(p.Position.X), + float32(p.Position.Y), + float32(p.Radius), + c, + true, + ) + } +}