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.
256 lines
6.7 KiB
Go
256 lines
6.7 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]
|
|
}
|
|
|
|
// 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
|
|
}
|