feat: initial commit with M1-M4 implementation
This commit is contained in:
44
cmd/game/main.go
Normal file
44
cmd/game/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
12
docs/plans/2026-05-08-hockey-impl.md
Normal file
12
docs/plans/2026-05-08-hockey-impl.md
Normal file
@@ -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)
|
||||
187
docs/plans/2026-05-08-m1-engine.md
Normal file
187
docs/plans/2026-05-08-m1-engine.md
Normal file
@@ -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"`
|
||||
146
docs/plans/2026-05-08-physics-dynamics.md
Normal file
146
docs/plans/2026-05-08-physics-dynamics.md
Normal file
@@ -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"
|
||||
```
|
||||
100
docs/plans/2026-05-09-players-ai.md
Normal file
100
docs/plans/2026-05-09-players-ai.md
Normal file
@@ -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"`
|
||||
151
docs/plans/2026-05-09-tactical-ai.md
Normal file
151
docs/plans/2026-05-09-tactical-ai.md
Normal file
@@ -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"`
|
||||
102
docs/plans/2026-05-12-match-logic-impl.md
Normal file
102
docs/plans/2026-05-12-match-logic-impl.md
Normal file
@@ -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"`
|
||||
17
docs/reviews/2026-05-08-physics-dynamics.md
Normal file
17
docs/reviews/2026-05-08-physics-dynamics.md
Normal file
@@ -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`).
|
||||
|
||||
Итог: Критические баги устранены, тесты проходят.
|
||||
9
docs/reviews/2026-05-09-ai-fixes.md
Normal file
9
docs/reviews/2026-05-09-ai-fixes.md
Normal file
@@ -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 обновлены и проходят.
|
||||
19
docs/reviews/2026-05-09-ai-tests.md
Normal file
19
docs/reviews/2026-05-09-ai-tests.md
Normal file
@@ -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`).
|
||||
102
docs/specs/2026-05-08-hockey-design.md
Normal file
102
docs/specs/2026-05-08-hockey-design.md
Normal file
@@ -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
|
||||
31
docs/specs/2026-05-08-physics-dynamics-design.md
Normal file
31
docs/specs/2026-05-08-physics-dynamics-design.md
Normal file
@@ -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.
|
||||
49
docs/specs/2026-05-08-player-ai-design.md
Normal file
49
docs/specs/2026-05-08-player-ai-design.md
Normal file
@@ -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.
|
||||
55
docs/specs/2026-05-09-players-ai-design.md
Normal file
55
docs/specs/2026-05-09-players-ai-design.md
Normal file
@@ -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.
|
||||
44
docs/specs/2026-05-09-tactical-ai-design.md
Normal file
44
docs/specs/2026-05-09-tactical-ai-design.md
Normal file
@@ -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.
|
||||
75
docs/specs/2026-05-12-match-logic-design.md
Normal file
75
docs/specs/2026-05-12-match-logic-design.md
Normal file
@@ -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.
|
||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -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
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
@@ -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=
|
||||
40
internal/entities/player.go
Normal file
40
internal/entities/player.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
19
internal/entities/puck.go
Normal file
19
internal/entities/puck.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
258
internal/game/world.go
Normal file
258
internal/game/world.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
233
internal/game/world_test.go
Normal file
233
internal/game/world_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
internal/render/renderer.go
Normal file
47
internal/render/renderer.go
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user