feat: initial commit with M1-M4 implementation

This commit is contained in:
Vladimir V Maksimov
2026-05-12 10:54:09 +03:00
commit aece34fe73
24 changed files with 1770 additions and 0 deletions

View 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)

View 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"`

View 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"
```

View 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"`

View 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"`

View 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"`