Skip to content

Commit

Permalink
Merge pull request #5 from silverbucket/pathfinding-heatmap
Browse files Browse the repository at this point in the history
Basic NPC Pathfinding
  • Loading branch information
silverbucket authored Jan 10, 2025
2 parents 8b36a9e + 764c3d4 commit 18f2675
Show file tree
Hide file tree
Showing 14 changed files with 434 additions and 139 deletions.
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# pirate-wars
A pirate-themed roguelike, written in Go.

![pirate-wars](https://storage.5apps.com/silverbucket/public/shares/250104-0217-Screenshot%202025-01-04%20at%2003.16.47.jpg)
![pirate-wars](https://storage.5apps.com/silverbucket/public/shares/250110-1732-Screenshot%202025-01-10%20at%2018.31.52.jpg)

## Keybindings

Expand All @@ -18,20 +18,26 @@ A pirate-themed roguelike, written in Go.
* Move around in your boat
* Explore the map
* Visit towns (currently you cannot enter them)
* View mini-map of entire world, with towns
* View mini-map of entire world, with towns listed (`m`)
* NPC boats with basic pathfinding AI

### Towns
* Don't spawn towns in small land-locked areas
* Ghost towns

## Todo

#### Towns
* Enter towns
* Make towns look better
* Buy/sell goods
* Found you own town? (Pirate hideaway?)
* Don't let town spawn in small land-locked areas
* Found your own town? (Pirate hideaway?)

#### World Map
* Encounter enemies
* Enemy movement AI
* Engage with NPCs
* Improved NPC AI
* Hire/Dig channels pathways?
* Land defenses/fortifications

### Ships
* View ship details
Expand Down
44 changes: 37 additions & 7 deletions cmd/common/common.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package common

import (
"fmt"
"math/rand"
)

const (
LogFile = "pirate-wars.log"
WorldWidth = 600
WorldHeight = 600
TotalTowns = 30
ViewWidth = 75
ViewHeight = 50
MiniMapFactor = 11
LogFile = "pirate-wars.log"
WorldWidth = 600 // Y
WorldHeight = 600 // X
TotalTowns = 20
ViewWidth = 75
ViewHeight = 50
MiniMapFactor = 11
TotalNpcs = 50
MaxMovementCost = 9999
)

type ViewPort struct {
Expand All @@ -20,3 +27,26 @@ type Coordinates struct {
X int
Y int
}

// Directions to explore (up, down, left, right)
var Directions = []Coordinates{
{-1, 0}, // up
{-1, -1}, // up left
{-1, 1}, // up right
{1, 0}, // down
{1, -1}, // down left
{1, 1}, // down right
{0, -1}, // left
{0, 1}, // right
}

var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")

func GenID(pos Coordinates) string {
b := letterRunes[rand.Intn(len(letterRunes))]
return string(fmt.Sprintf("%v%03d%03d", string(b), pos.X, pos.Y))
}

func Inbounds(c Coordinates) bool {
return c.X >= 0 && c.X < WorldHeight && c.Y >= 0 && c.Y < WorldWidth
}
2 changes: 1 addition & 1 deletion cmd/player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import (
"pirate-wars/cmd/terrain"
)

func Create(t *terrain.Terrain) terrain.Avatar {
func Create(t *terrain.Terrain) *terrain.Avatar {
return terrain.CreateAvatar(t.RandomPositionDeepWater(), '⏏', terrain.ColorScheme{"#000000", "#ffffff"})
}
13 changes: 8 additions & 5 deletions cmd/terrain/avatar.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ func (a *Avatar) SetY(y int) {
a.pos.Y = y
}

func (a *Avatar) SetXY(c common.Coordinates) {
a.pos.X = c.X
a.pos.Y = c.Y
func (a *Avatar) SetPos(c common.Coordinates) {
a.pos = c
}

func (a *Avatar) GetPos() common.Coordinates {
return a.pos
}

func (a *Avatar) Render() string {
Expand All @@ -53,6 +56,6 @@ func (a *Avatar) Render() string {
Render("%c"), a.char)
}

func CreateAvatar(coordinates common.Coordinates, c rune, color ColorScheme) Avatar {
return Avatar{pos: coordinates, char: c, fgColor: lipgloss.Color(color.Foreground), bgColor: lipgloss.Color(color.Background)}
func CreateAvatar(coordinates common.Coordinates, c rune, color ColorScheme) *Avatar {
return &Avatar{pos: coordinates, char: c, fgColor: lipgloss.Color(color.Foreground), bgColor: lipgloss.Color(color.Background)}
}
82 changes: 82 additions & 0 deletions cmd/terrain/heatmap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package terrain

import (
"fmt"
"pirate-wars/cmd/common"
)

func (t *Terrain) GenerateTownHeatMap(town *Town) bool {
// Define the starting point
startX, startY := town.GetX(), town.GetY()

// Queue contains points to visit, cost
queue := [][]int{{startX, startY, 0}}
count := 0
// Perform Breadth-First Search
for len(queue) > 0 {
count++
//t.Logger.Debugf("Queue length: %d", len(queue))
j := queue[0]
queue = queue[1:]

c := common.Coordinates{j[0], j[1]}
cost := j[2]

//t.Logger.Infof("[towm %v] Processing %v, %v", t`own, x, y)
if t.World.IsPassableByBoat(c) {
//t.Logger.Debug(fmt.Sprintf("[town %v] Assigning cost %v, %v = %v [%v]", town, x, y, cost, t.Towns[town].heatMap[x][y]))
if t.World.GetPositionType(c) == TypeShallowWater {
// shallow water costs more (dangerous)
town.SetHeatmapCost(c, cost+3)
cost = cost + 4
} else if t.World.GetPositionType(c) == TypeOpenWater {
// open water faster than shallow, but not as fast as deep
town.SetHeatmapCost(c, cost+1)
cost = cost + 2
} else {
town.SetHeatmapCost(c, cost)
cost = cost + 1
}
} else {
if cost == 0 && t.World.GetPositionType(c) == TypeTown {
// starting town is the cheapest
town.SetHeatmapCost(c, cost)
} else {
// land is impassible
town.SetHeatmapCost(c, common.MaxMovementCost)
}
}

// Explore neighbors
for _, dir := range common.Directions {
n := common.Coordinates{c.X + dir.X, c.Y + dir.Y}

//t.Logger.Debug(fmt.Sprintf("[town %v] Prepping direction %v, %v cost:%v", town, newX, newY, cost))

// Check if the new point is within bounds and not visited
if common.Inbounds(n) && town.GetHeatmapCost(n) == -1 {
if !t.World.IsPassableByBoat(n) && t.World.GetPositionType(n) != TypeTown {
town.SetHeatmapCost(n, common.MaxMovementCost)
} else {
//t.Logger.Debug(fmt.Sprintf("[town %v] (%v, %v) Adding direction %v, %v -- heatmap:%v", town, x, y, newX, newY, t.Towns[town].heatMap[newX][newY]))
town.SetHeatmapCost(n, -2)
queue = append(queue, []int{n.X, n.Y, cost})
}
}
}
}
if count < 200 {
t.Logger.Debug(fmt.Sprintf("[%v] Town at %v heatmap aborted with %v iterations", town.GetId(), town.GetPos(), count))
return false
} else {
t.Logger.Debug(fmt.Sprintf("[%v] Town at %v heatmap completed with %v iterations", town.GetId(), town.GetPos(), count))
return true
}
}

//func (t *Terrain) GenerateHeatMaps() {
// for _, town := range t.Towns {
// t.Logger.Info(fmt.Sprintf("Generating heatmap for town at %v, %v", town.pos.X, town.pos.Y))
// t.GenerateTownHeatMap(&town)
// }
//}
112 changes: 101 additions & 11 deletions cmd/terrain/npc.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
package terrain

import (
"fmt"
"math/rand"
"pirate-wars/cmd/common"
)

const NUM_NPCS = 50

type ColorScheme struct {
Foreground string
Background string
}

// ChanceToMove Percentage chance an NPC will calculate movement per tick
const ChanceToMove = 50

const GoalTypeTrade = 1

type Agenda struct {
goal int
tradeTarget int
tadeRoute []Town
}

type Npc struct {
id string
avatar *Avatar
agenda Agenda
}

var ColorPossibilities = []ColorScheme{
{"#AA0000", "#000000"},
{"#00AA00", "#000000"},
Expand All @@ -22,17 +39,90 @@ var ColorPossibilities = []ColorScheme{
{"#00FFFF", "#000000"},
}

var NPCs []Avatar

func (t *Terrain) CreateNPC() {
func (t *Terrain) CreateNpc() {
pos := t.RandomPositionDeepWater()
t.Logger.Infof("Creating NPC at %d, %d", pos.X, pos.Y)
npc := CreateAvatar(pos, '⏏', ColorPossibilities[rand.Intn(len(ColorPossibilities)-1)])
NPCs = append(NPCs, npc)
firstTown := t.GetRandomTown()
secondTown := t.GetRandomTown()
for {
// ensure towns are unique
if secondTown.GetY() == firstTown.GetY() && secondTown.GetY() == firstTown.GetY() {
secondTown = t.GetRandomTown()
} else {
break
}
}
npc := Npc{
id: common.GenID(pos),
avatar: CreateAvatar(pos, '⏏', ColorPossibilities[rand.Intn(len(ColorPossibilities)-1)]),
agenda: Agenda{
goal: GoalTypeTrade,
tradeTarget: 0,
tadeRoute: []Town{firstTown, secondTown},
},
}
t.Logger.Infof("[%v] NPC created at %d, %d", npc.id, pos.X, pos.Y)
t.Npcs = append(t.Npcs, npc)
}

func (t *Terrain) InitNpcs() {
for i := 0; i < common.TotalNpcs; i++ {
t.CreateNpc()
}
}

func (t *Terrain) CalcNpcMovements() {
for i := range t.Npcs {
if rand.Intn(100) > ChanceToMove {
continue
}

npc := &t.Npcs[i]
target := npc.avatar.GetPos()
town := &npc.agenda.tadeRoute[npc.agenda.tradeTarget]

// if we're already at our destination, flip our trade route
if town.GetHeatmapCost(target) < 3 {
oldTown := npc.agenda.tadeRoute[npc.agenda.tradeTarget]
npc.agenda.tradeTarget = npc.agenda.tradeTarget ^ 1
town = &npc.agenda.tadeRoute[npc.agenda.tradeTarget]
t.Logger.Info(fmt.Sprintf("[%v] NPC movement trade switch from town %v to town %v", npc.id, oldTown.GetPos(), town.GetPos()))
}

// find next move by cost on heatmap
var lowestCost = common.MaxMovementCost
for _, dir := range common.Directions {
n := common.Coordinates{npc.avatar.GetX() + dir.X, npc.avatar.GetY() + dir.Y}
if !common.Inbounds(n) {
// don't check out of bounds
continue
}

//t.Logger.Debug(fmt.Sprintf("New heatmap coordinates check [%v][%v]", newX, newY))
//t.Logger.Debug(fmt.Sprintf("Npc at %v, %v - checking square %v, %v cost:%v [lowest cost: %v]", newPosition.X, newPosition.Y, newX, newY, town.heatMap[newX][newY], lowestCost)
cost := town.GetHeatmapCost(n)
if cost >= 0 && cost < lowestCost {
lowestCost = cost
target = n
}
}

if target.X == npc.avatar.GetX() && target.Y == npc.avatar.GetY() {
t.Logger.Debug(fmt.Sprintf("[%v] NPC stuck! Travelling to town at %v (cost %v)", npc.id, town.GetPos(), town.GetHeatmapCost(target)))
} else {
pos := npc.avatar.GetPos()
t.Logger.Info(fmt.Sprintf("[%v] NPC moving from %v to %v", npc.id, pos, target))
if !t.isPositionAdjacent(pos, target) {
t.Logger.Warn(fmt.Sprintf("[%v] NPC warp!", npc.id))
}
npc.avatar.SetPos(target)
}
}
}

func (t *Terrain) InitNPCs() {
for i := 0; i < NUM_NPCS; i++ {
t.CreateNPC()
func (t *Terrain) GetNpcAvatars() []AvatarReadOnly {
var avs []AvatarReadOnly
for npc := range t.Npcs {
avs = append(avs, t.Npcs[npc].avatar)
}
return avs
}
Loading

0 comments on commit 18f2675

Please sign in to comment.