You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

274 lines
7.0 KiB
Go

package game
import (
"context"
"fmt"
"math"
"math/rand"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/model"
)
// Point is a 2D coordinate on the world map.
type Point struct {
X, Y float64
}
// Road connects two towns with a sequence of waypoints.
type Road struct {
ID int64
FromTownID int64
ToTownID int64
Waypoints []Point // ordered: first = from-town center, last = to-town center
Distance float64
}
// TownNPC is a quest/shop NPC placed in a town (from npcs table).
type TownNPC struct {
ID int64
Name string
Type string
}
// RoadGraph is an immutable in-memory graph of all roads and towns,
// loaded once at startup.
type RoadGraph struct {
Roads map[int64]*Road // road ID -> road
TownRoads map[int64][]*Road // town ID -> outgoing roads
Towns map[int64]*model.Town // town ID -> town
TownOrder []int64 // ordered town IDs for sequential traversal
TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order)
NPCByID map[int64]TownNPC // NPC id -> row
}
// LoadRoadGraph reads roads and towns from the database, generates waypoints
// deterministically, and returns an immutable RoadGraph.
func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) {
g := &RoadGraph{
Roads: make(map[int64]*Road),
TownRoads: make(map[int64][]*Road),
Towns: make(map[int64]*model.Town),
TownNPCs: make(map[int64][]TownNPC),
NPCByID: make(map[int64]TownNPC),
}
// Load towns.
rows, err := pool.Query(ctx, `SELECT id, name, biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`)
if err != nil {
return nil, fmt.Errorf("load towns: %w", err)
}
defer rows.Close()
for rows.Next() {
var t model.Town
if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
return nil, fmt.Errorf("scan town: %w", err)
}
g.Towns[t.ID] = &t
g.TownOrder = append(g.TownOrder, t.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate towns: %w", err)
}
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type FROM npcs ORDER BY town_id, id`)
if err != nil {
return nil, fmt.Errorf("load npcs: %w", err)
}
defer npcRows.Close()
for npcRows.Next() {
var n TownNPC
var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
g.NPCByID[n.ID] = n
g.TownNPCs[townID] = append(g.TownNPCs[townID], n)
}
if err := npcRows.Err(); err != nil {
return nil, fmt.Errorf("iterate npcs: %w", err)
}
// Load roads.
roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`)
if err != nil {
return nil, fmt.Errorf("load roads: %w", err)
}
defer roadRows.Close()
for roadRows.Next() {
var r Road
if err := roadRows.Scan(&r.ID, &r.FromTownID, &r.ToTownID, &r.Distance); err != nil {
return nil, fmt.Errorf("scan road: %w", err)
}
fromTown, ok := g.Towns[r.FromTownID]
if !ok {
continue
}
toTown, ok := g.Towns[r.ToTownID]
if !ok {
continue
}
r.Waypoints = generateWaypoints(fromTown, toTown, r.ID)
r.Distance = totalWaypointDistance(r.Waypoints)
g.Roads[r.ID] = &r
g.TownRoads[r.FromTownID] = append(g.TownRoads[r.FromTownID], &r)
}
if err := roadRows.Err(); err != nil {
return nil, fmt.Errorf("iterate roads: %w", err)
}
return g, nil
}
// TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues).
func (g *RoadGraph) TownNPCIDs(townID int64) []int64 {
list := g.TownNPCs[townID]
ids := make([]int64, len(list))
for i := range list {
ids[i] = list[i].ID
}
return ids
}
// FindRoad returns the road from fromTownID to toTownID, if it exists.
func (g *RoadGraph) FindRoad(fromTownID, toTownID int64) *Road {
for _, r := range g.TownRoads[fromTownID] {
if r.ToTownID == toTownID {
return r
}
}
return nil
}
// NextTownInChain returns the next town along the ring (TownOrder order, wraps after last).
func (g *RoadGraph) NextTownInChain(currentTownID int64) int64 {
n := len(g.TownOrder)
if n == 0 {
return currentTownID
}
idx := -1
for i, id := range g.TownOrder {
if id == currentTownID {
idx = i
break
}
}
if idx < 0 {
return g.TownOrder[0]
}
return g.TownOrder[(idx+1)%n]
}
// HeroInTownAt returns true if (x, y) lies inside any town's radius (vendor / sell zone).
func (g *RoadGraph) HeroInTownAt(x, y float64) bool {
if g == nil {
return false
}
for _, t := range g.Towns {
if t == nil {
continue
}
dx := x - t.WorldX
dy := y - t.WorldY
if dx*dx+dy*dy <= t.Radius*t.Radius {
return true
}
}
return false
}
// NearestTown returns the town ID closest to the given world position.
func (g *RoadGraph) NearestTown(x, y float64) int64 {
bestDist := math.MaxFloat64
var bestID int64
for _, t := range g.Towns {
d := math.Hypot(t.WorldX-x, t.WorldY-y)
if d < bestDist {
bestDist = d
bestID = t.ID
}
}
return bestID
}
// TownOrderIndex returns the index of a town in the ordered chain, or -1 if not found.
func (g *RoadGraph) TownOrderIndex(townID int64) int {
for i, id := range g.TownOrder {
if id == townID {
return i
}
}
return -1
}
const (
waypointSpacing = 20.0 // tiles between jitter points
waypointJitter = 2.0 // +/- tile jitter
)
// generateWaypoints creates a deterministic sequence of points from one town center
// to another, with jitter applied every waypointSpacing tiles.
func generateWaypoints(from, to *model.Town, roadID int64) []Point {
dx := to.WorldX - from.WorldX
dy := to.WorldY - from.WorldY
totalDist := math.Hypot(dx, dy)
if totalDist < 1 {
return []Point{{from.WorldX, from.WorldY}, {to.WorldX, to.WorldY}}
}
numSegments := int(totalDist / waypointSpacing)
if numSegments < 1 {
numSegments = 1
}
// Deterministic RNG per road.
rng := rand.New(rand.NewSource(roadID * 7919))
points := make([]Point, 0, numSegments+2)
points = append(points, Point{from.WorldX, from.WorldY})
for i := 1; i < numSegments; i++ {
t := float64(i) / float64(numSegments)
px := from.WorldX + dx*t + (rng.Float64()*2-1)*waypointJitter
py := from.WorldY + dy*t + (rng.Float64()*2-1)*waypointJitter
points = append(points, Point{px, py})
}
points = append(points, Point{to.WorldX, to.WorldY})
return points
}
// totalWaypointDistance computes the sum of segment lengths along a waypoint path.
func totalWaypointDistance(pts []Point) float64 {
var total float64
for i := 1; i < len(pts); i++ {
total += math.Hypot(pts[i].X-pts[i-1].X, pts[i].Y-pts[i-1].Y)
}
return total
}
// SortedTownsByDistance returns town IDs sorted by Euclidean distance from (x,y).
func (g *RoadGraph) SortedTownsByDistance(x, y float64) []int64 {
type td struct {
id int64
dist float64
}
tds := make([]td, 0, len(g.Towns))
for _, t := range g.Towns {
tds = append(tds, td{id: t.ID, dist: math.Hypot(t.WorldX-x, t.WorldY-y)})
}
sort.Slice(tds, func(i, j int) bool { return tds[i].dist < tds[j].dist })
ids := make([]int64, len(tds))
for i, v := range tds {
ids[i] = v.id
}
return ids
}