feat: server map generator
This commit is contained in:
parent
86d78c0f0c
commit
9f9a575313
|
@ -505,6 +505,9 @@ issues:
|
|||
linters:
|
||||
- gosec
|
||||
- gocyclo
|
||||
- path: server_map.go
|
||||
linters:
|
||||
- mnd
|
||||
- path: _test\.go
|
||||
text: add-constant
|
||||
- path: bun/migrations
|
||||
|
|
1
go.mod
1
go.mod
|
@ -28,6 +28,7 @@ require (
|
|||
github.com/uptrace/bun/extra/bundebug v1.2.1
|
||||
github.com/urfave/cli/v2 v2.27.2
|
||||
go.uber.org/automaxprocs v1.5.3
|
||||
golang.org/x/image v0.17.0
|
||||
golang.org/x/sync v0.7.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
|
2
go.sum
2
go.sum
|
@ -233,6 +233,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco=
|
||||
golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
|
|
|
@ -20,7 +20,9 @@ func NewServerConfig(tb TestingTB) domain.ServerConfig {
|
|||
domain.ServerConfigBuildings{},
|
||||
domain.ServerConfigSnob{},
|
||||
domain.ServerConfigAlly{Limit: gofakeit.IntRange(1, 20)},
|
||||
domain.ServerConfigCoord{},
|
||||
domain.ServerConfigCoord{
|
||||
MapSize: gofakeit.IntRange(1000, 1000),
|
||||
},
|
||||
domain.ServerConfigSitter{},
|
||||
domain.ServerConfigSleep{},
|
||||
domain.ServerConfigNight{},
|
||||
|
|
|
@ -37,6 +37,8 @@ func NewVillageCursor(tb TestingTB, opts ...func(cfg *VillageCursorConfig)) doma
|
|||
type VillageConfig struct {
|
||||
ID int
|
||||
ServerKey string
|
||||
X int
|
||||
Y int
|
||||
PlayerID int
|
||||
}
|
||||
|
||||
|
@ -46,6 +48,8 @@ func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village {
|
|||
cfg := &VillageConfig{
|
||||
ID: RandID(),
|
||||
ServerKey: RandServerKey(),
|
||||
X: gofakeit.IntRange(1, 999),
|
||||
Y: gofakeit.IntRange(1, 999),
|
||||
PlayerID: gofakeit.IntRange(0, 10000),
|
||||
}
|
||||
|
||||
|
@ -58,8 +62,8 @@ func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village {
|
|||
cfg.ServerKey,
|
||||
gofakeit.LetterN(50),
|
||||
gofakeit.IntRange(1, 10000),
|
||||
gofakeit.IntRange(1, 999),
|
||||
gofakeit.IntRange(1, 999),
|
||||
cfg.X,
|
||||
cfg.Y,
|
||||
gofakeit.LetterN(3),
|
||||
0,
|
||||
cfg.PlayerID,
|
||||
|
|
|
@ -1,23 +1,105 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
type ServerMapMarker struct {
|
||||
village VillageMeta
|
||||
color color.RGBA
|
||||
}
|
||||
|
||||
func NewServerMapMarker(village VillageMeta) ServerMapMarker {
|
||||
return ServerMapMarker{
|
||||
village: village,
|
||||
color: color.RGBA{R: 255, G: 99, B: 71, A: 255},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ServerMapMarker) Village() VillageMeta {
|
||||
return m.village
|
||||
}
|
||||
|
||||
func (m *ServerMapMarker) SetColor(c color.RGBA) error {
|
||||
m.color = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMapMarker) Color() color.RGBA {
|
||||
return m.color
|
||||
}
|
||||
|
||||
type ServerMapLegendEntry struct {
|
||||
color color.Color
|
||||
names []string
|
||||
}
|
||||
|
||||
func NewServerMapLegendEntry(c color.Color, names ...string) ServerMapLegendEntry {
|
||||
return ServerMapLegendEntry{color: c, names: names}
|
||||
}
|
||||
|
||||
func (e ServerMapLegendEntry) Color() color.Color {
|
||||
return e.color
|
||||
}
|
||||
|
||||
func (e ServerMapLegendEntry) Names() []string {
|
||||
return e.names
|
||||
}
|
||||
|
||||
type ServerMap struct {
|
||||
server Server
|
||||
villagesCh <-chan VillageMetaWithRelations
|
||||
size int
|
||||
markersCh <-chan ServerMapMarker
|
||||
backgroundColor color.RGBA
|
||||
scale float32
|
||||
centerX int
|
||||
centerY int
|
||||
continentGrid bool
|
||||
gridLineColor color.RGBA
|
||||
continentNumbers bool
|
||||
continentNumberColor color.RGBA
|
||||
legendEntries []ServerMapLegendEntry
|
||||
}
|
||||
|
||||
const serverMapModelName = "ServerMap"
|
||||
|
||||
func NewServerMap(server Server, villagesCh <-chan VillageMetaWithRelations) ServerMap {
|
||||
func NewServerMap(server Server, markersCh <-chan ServerMapMarker) ServerMap {
|
||||
size := server.Config().Coord().MapSize
|
||||
|
||||
return ServerMap{
|
||||
server: server,
|
||||
villagesCh: villagesCh,
|
||||
size: size,
|
||||
markersCh: markersCh,
|
||||
backgroundColor: color.RGBA{
|
||||
A: 255,
|
||||
},
|
||||
scale: 1,
|
||||
centerX: size / 2,
|
||||
centerY: size / 2,
|
||||
continentGrid: true,
|
||||
gridLineColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
continentNumbers: true,
|
||||
continentNumberColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
serverMapMinScale = 1
|
||||
serverMapMaxScale = 5
|
||||
)
|
||||
|
||||
func (m *ServerMap) SetScale(scale float32) error {
|
||||
if err := validateInRange(scale, 0, 1); err != nil {
|
||||
if err := validateInRange(scale, serverMapMinScale, serverMapMaxScale); err != nil {
|
||||
return ValidationError{
|
||||
Model: serverMapModelName,
|
||||
Field: "scale",
|
||||
|
@ -29,3 +111,239 @@ func (m *ServerMap) SetScale(scale float32) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetBackgroundColor(backgroundColor color.RGBA) error {
|
||||
m.backgroundColor = backgroundColor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetCenterX(centerX int) error {
|
||||
m.centerX = centerX
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetCenterY(centerY int) error {
|
||||
m.centerY = centerY
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetContinentGrid(continentGrid bool) error {
|
||||
m.continentGrid = continentGrid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetGridLineColor(gridLineColor color.RGBA) error {
|
||||
m.gridLineColor = gridLineColor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetContinentNumbers(continentNumbers bool) error {
|
||||
m.continentNumbers = continentNumbers
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetContinentNumberColor(continentNumberColor color.RGBA) error {
|
||||
m.continentNumberColor = continentNumberColor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) SetLegendEntries(legendEntries []ServerMapLegendEntry) error {
|
||||
m.legendEntries = legendEntries
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ServerMap) Generate(w io.Writer) error {
|
||||
r := image.Rectangle{Max: image.Point{X: m.size, Y: m.size}}
|
||||
imgHalfWidth := r.Dx() / 2
|
||||
imgHalfHeight := r.Dy() / 2
|
||||
|
||||
img := image.NewRGBA(r)
|
||||
|
||||
draw.Draw(img, img.Bounds(), image.NewUniform(m.backgroundColor), image.Point{}, draw.Src)
|
||||
m.drawContinentGrid(img)
|
||||
m.drawMarkers(img)
|
||||
m.drawContinentNumbers(img)
|
||||
legend, err := m.generateLegend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scaledImg := m.scaleImg(img)
|
||||
|
||||
final := image.NewRGBA(image.Rect(0, 0, r.Dx()+legend.Rect.Dx(), r.Dy()))
|
||||
draw.Draw(final, scaledImg.Rect, scaledImg, image.Point{
|
||||
X: int(float32(m.centerX)*m.scale) - imgHalfWidth,
|
||||
Y: int(float32(m.centerY)*m.scale) - imgHalfHeight,
|
||||
}, draw.Src)
|
||||
draw.Draw(final, image.Rect(r.Dx(), 0, final.Rect.Dx(), final.Rect.Dy()), legend, image.Point{}, draw.Src)
|
||||
|
||||
if err = png.Encode(w, final); err != nil {
|
||||
return fmt.Errorf("couldn't encode image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const serverMapContinentDx = 100
|
||||
|
||||
func (m *ServerMap) drawContinentGrid(img *image.RGBA) {
|
||||
if !m.continentGrid {
|
||||
return
|
||||
}
|
||||
|
||||
for y := serverMapContinentDx; y < m.size; y += serverMapContinentDx {
|
||||
for x := range m.size {
|
||||
img.Set(x, y, m.gridLineColor)
|
||||
img.Set(y, x, m.gridLineColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverMapMarkerRectSideLength = 1
|
||||
|
||||
func (m *ServerMap) drawMarkers(img *image.RGBA) {
|
||||
for marker := range m.markersCh {
|
||||
village := marker.Village()
|
||||
x := village.X()
|
||||
y := village.Y()
|
||||
rect := image.Rect(x, y, x+serverMapMarkerRectSideLength, y+serverMapMarkerRectSideLength)
|
||||
draw.Draw(img, rect, &image.Uniform{C: marker.color}, image.Point{}, draw.Over)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ServerMap) drawContinentNumbers(img *image.RGBA) {
|
||||
if !m.continentNumbers {
|
||||
return
|
||||
}
|
||||
|
||||
continent := 0
|
||||
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(m.continentNumberColor),
|
||||
Face: basicfont.Face7x13,
|
||||
}
|
||||
|
||||
for y := serverMapContinentDx; y <= m.size; y += serverMapContinentDx {
|
||||
for x := serverMapContinentDx; x <= m.size; x += serverMapContinentDx {
|
||||
continentStr := strconv.Itoa(continent)
|
||||
|
||||
d.Dot = fixed.Point26_6{
|
||||
X: fixed.I(x) - d.MeasureString(continentStr),
|
||||
Y: fixed.I(y - 1),
|
||||
}
|
||||
|
||||
d.DrawString(continentStr)
|
||||
|
||||
continent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ServerMap) scaleImg(img *image.RGBA) *image.RGBA {
|
||||
if m.scale == 1 {
|
||||
return img
|
||||
}
|
||||
|
||||
scaledSize := int(float32(m.size) * m.scale)
|
||||
scaledImg := image.NewRGBA(image.Rect(0, 0, scaledSize, scaledSize))
|
||||
draw.NearestNeighbor.Scale(scaledImg, scaledImg.Rect, img, img.Rect, draw.Over, nil)
|
||||
|
||||
return scaledImg
|
||||
}
|
||||
|
||||
const (
|
||||
serverMapLegendWidth = 400
|
||||
serverMapLegendPadding = 10
|
||||
serverMapColorWidth = 40
|
||||
serverMapSpaceColorText = 10
|
||||
serverMapLegendStringSeparator = " + "
|
||||
serverMapLegendStringOverflow = "..."
|
||||
serverMapLegendMaxStringLength = 45
|
||||
)
|
||||
|
||||
var ErrTooManyLegendEntries error = simpleError{
|
||||
msg: "too many legend entries",
|
||||
typ: ErrorTypeIncorrectInput,
|
||||
code: "too-many-legend-entries",
|
||||
}
|
||||
|
||||
func (m *ServerMap) generateLegend() (*image.RGBA, error) {
|
||||
if len(m.legendEntries) == 0 {
|
||||
return image.NewRGBA(image.Rectangle{}), nil
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rectangle{Max: image.Point{X: serverMapLegendWidth, Y: m.size}})
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(m.continentNumberColor),
|
||||
Face: basicfont.Face7x13,
|
||||
}
|
||||
lineHeight := basicfont.Face7x13.Metrics().Ascent.Ceil() + basicfont.Face7x13.Metrics().Descent.Ceil()
|
||||
lineMaxWidth := img.Rect.Dx() - serverMapLegendPadding*2 - serverMapColorWidth - serverMapSpaceColorText
|
||||
offset := serverMapLegendPadding
|
||||
|
||||
for _, e := range m.legendEntries {
|
||||
namesLen := len(e.Names())
|
||||
|
||||
currentWidth := 0
|
||||
line := ""
|
||||
var lines []string
|
||||
|
||||
for i, name := range e.Names() {
|
||||
if len(name) > serverMapLegendMaxStringLength {
|
||||
name = name[:serverMapLegendMaxStringLength-len(serverMapLegendStringOverflow)] + "..."
|
||||
}
|
||||
|
||||
if i != namesLen-1 {
|
||||
name += serverMapLegendStringSeparator
|
||||
}
|
||||
|
||||
strWidth := d.MeasureString(name).Ceil()
|
||||
|
||||
if currentWidth+strWidth > lineMaxWidth {
|
||||
lines = append(lines, line)
|
||||
currentWidth = 0
|
||||
line = ""
|
||||
}
|
||||
|
||||
line += name
|
||||
currentWidth += strWidth
|
||||
}
|
||||
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
yMin := offset + (len(lines) * lineHeight / 2) - lineHeight/2
|
||||
|
||||
draw.Draw(
|
||||
img,
|
||||
image.Rect(
|
||||
serverMapLegendPadding,
|
||||
yMin,
|
||||
serverMapColorWidth+serverMapLegendPadding,
|
||||
yMin+lineHeight,
|
||||
),
|
||||
image.NewUniform(e.Color()),
|
||||
image.Point{},
|
||||
draw.Src,
|
||||
)
|
||||
|
||||
for i, l := range lines {
|
||||
d.Dot = fixed.Point26_6{
|
||||
X: fixed.I(serverMapColorWidth + serverMapLegendPadding + serverMapSpaceColorText),
|
||||
Y: fixed.I(offset + (i+1)*lineHeight),
|
||||
}
|
||||
d.DrawString(l)
|
||||
}
|
||||
|
||||
offset += (len(lines) * lineHeight) + serverMapLegendPadding
|
||||
|
||||
if offset > img.Bounds().Dy()-serverMapLegendPadding {
|
||||
return nil, ErrTooManyLegendEntries
|
||||
}
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
|
91
internal/domain/server_map_test.go
Normal file
91
internal/domain/server_map_test.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package domain_test
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.dwysokinski.me/twhelp/core/internal/domain"
|
||||
"gitea.dwysokinski.me/twhelp/core/internal/domain/domaintest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServerMap_Generate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK: default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := make(chan domain.ServerMapMarker)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for i := range 500 {
|
||||
ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) {
|
||||
cfg.X = i
|
||||
cfg.Y = i
|
||||
}).Meta())
|
||||
}
|
||||
}()
|
||||
|
||||
m := domain.NewServerMap(domaintest.NewServer(t), ch)
|
||||
require.NoError(t, m.Generate(io.Discard))
|
||||
})
|
||||
|
||||
t.Run("OK: scale=2 legend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := make(chan domain.ServerMapMarker)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for i := range 500 {
|
||||
ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) {
|
||||
cfg.X = i
|
||||
cfg.Y = i
|
||||
}).Meta())
|
||||
}
|
||||
}()
|
||||
|
||||
m := domain.NewServerMap(domaintest.NewServer(t), ch)
|
||||
require.NoError(t, m.SetLegendEntries([]domain.ServerMapLegendEntry{
|
||||
domain.NewServerMapLegendEntry(
|
||||
color.RGBA{R: 255, G: 204, B: 229, A: 255},
|
||||
"test22222^",
|
||||
"test222",
|
||||
),
|
||||
domain.NewServerMapLegendEntry(
|
||||
color.RGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
"test22222^",
|
||||
"test2223",
|
||||
"longstringlongstringlongstringlongstring",
|
||||
"longstringlongstringlongstringlongstringlongstringlongstringlongstringlongstring",
|
||||
"longstringlongstring2",
|
||||
),
|
||||
}))
|
||||
require.NoError(t, m.SetScale(2))
|
||||
require.NoError(t, m.Generate(io.Discard))
|
||||
})
|
||||
|
||||
t.Run("OK: scale=2 centerX=250 centerY=250", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := make(chan domain.ServerMapMarker)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for i := range 500 {
|
||||
ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) {
|
||||
cfg.X = i
|
||||
cfg.Y = i
|
||||
}).Meta())
|
||||
}
|
||||
}()
|
||||
|
||||
m := domain.NewServerMap(domaintest.NewServer(t), ch)
|
||||
require.NoError(t, m.SetCenterX(250))
|
||||
require.NoError(t, m.SetCenterY(250))
|
||||
require.NoError(t, m.SetScale(2))
|
||||
require.NoError(t, m.Generate(io.Discard))
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user