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

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