413 lines
9.8 KiB
Go
413 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"math"
|
|
"math/rand"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/data/binding"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// PixelMask stores per-pixel occupancy for placement and collision checks.
|
|
type PixelMask struct {
|
|
Width int
|
|
Height int
|
|
Data []uint8
|
|
}
|
|
|
|
// NewPixelMask creates a mask for width and height and is used for feature occupancy.
|
|
func NewPixelMask(width, height int) *PixelMask {
|
|
if width <= 0 || height <= 0 {
|
|
return &PixelMask{}
|
|
}
|
|
return &PixelMask{
|
|
Width: width,
|
|
Height: height,
|
|
Data: make([]uint8, width*height),
|
|
}
|
|
}
|
|
|
|
// index returns the slice index for coordinates and is used by mask helpers.
|
|
func (m *PixelMask) index(x, y int) int {
|
|
return y*m.Width + x
|
|
}
|
|
|
|
// InBounds reports whether x,y are inside the mask and is used to guard access.
|
|
func (m *PixelMask) InBounds(x, y int) bool {
|
|
return m != nil && x >= 0 && y >= 0 && x < m.Width && y < m.Height
|
|
}
|
|
|
|
// GetXY returns occupancy at x,y and is used for collision checks.
|
|
func (m *PixelMask) GetXY(x, y int) bool {
|
|
if !m.InBounds(x, y) {
|
|
return false
|
|
}
|
|
return m.Data[m.index(x, y)] != 0
|
|
}
|
|
|
|
// SetXY marks x,y as occupied and is used when placing features.
|
|
func (m *PixelMask) SetXY(x, y int) {
|
|
if m.InBounds(x, y) {
|
|
m.Data[m.index(x, y)] = 1
|
|
}
|
|
}
|
|
|
|
// ClearXY clears occupancy at x,y and is used for mask edits.
|
|
func (m *PixelMask) ClearXY(x, y int) {
|
|
if m.InBounds(x, y) {
|
|
m.Data[m.index(x, y)] = 0
|
|
}
|
|
}
|
|
|
|
// GetPoint returns occupancy at a point and is used with image.Point helpers.
|
|
func (m *PixelMask) GetPoint(p image.Point) bool {
|
|
return m.GetXY(p.X, p.Y)
|
|
}
|
|
|
|
// SetPoint marks a point as occupied and is used with image.Point helpers.
|
|
func (m *PixelMask) SetPoint(p image.Point) {
|
|
m.SetXY(p.X, p.Y)
|
|
}
|
|
|
|
// Merge ORs another mask into this one and is used to combine feature masks.
|
|
func (m *PixelMask) Merge(other *PixelMask) {
|
|
if m == nil || other == nil || m.Width != other.Width || m.Height != other.Height {
|
|
return
|
|
}
|
|
for i := range m.Data {
|
|
if other.Data[i] != 0 {
|
|
m.Data[i] = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddPoints marks a slice of points as occupied and is used for lake/road masks.
|
|
func (m *PixelMask) AddPoints(points []image.Point) {
|
|
for _, p := range points {
|
|
m.SetPoint(p)
|
|
}
|
|
}
|
|
|
|
// ToPoints returns occupied points and is used to extract anchors.
|
|
func (m *PixelMask) ToPoints() []image.Point {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
pts := make([]image.Point, 0)
|
|
for y := 0; y < m.Height; y++ {
|
|
row := y * m.Width
|
|
for x := 0; x < m.Width; x++ {
|
|
if m.Data[row+x] != 0 {
|
|
pts = append(pts, image.Point{X: x, Y: y})
|
|
}
|
|
}
|
|
}
|
|
return pts
|
|
}
|
|
|
|
// BuildMaskFromLakes builds a water mask from lake point sets for later merges.
|
|
func BuildMaskFromLakes(width, height int, lakes [][]image.Point) *PixelMask {
|
|
mask := NewPixelMask(width, height)
|
|
for _, lake := range lakes {
|
|
mask.AddPoints(lake)
|
|
}
|
|
return mask
|
|
}
|
|
|
|
// SeedProvider provides a deterministic stream of seeds for generation steps.
|
|
type SeedProvider struct {
|
|
rand *rand.Rand
|
|
}
|
|
|
|
// NewSeedProvider creates a SeedProvider for splitting a root seed into steps.
|
|
func NewSeedProvider(seed int64) *SeedProvider {
|
|
return &SeedProvider{
|
|
rand: rand.New(rand.NewSource(seed)),
|
|
}
|
|
}
|
|
|
|
// Next returns the next random seed and is used to drive independent generators.
|
|
func (sp *SeedProvider) Next() int64 {
|
|
return sp.rand.Int63()
|
|
}
|
|
|
|
// averageImageDimension returns (width+height)/2 and is used for percent scaling.
|
|
func averageImageDimension(width, height int) float64 {
|
|
return (float64(width) + float64(height)) / 2.0
|
|
}
|
|
|
|
// clamp clamps v to [lo, hi] and is used for settings normalization.
|
|
func clamp(v, lo, hi float64) float64 {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
|
|
// clamp01 clamps v to [0,1] and is used for normalized weights.
|
|
func clamp01(v float64) float64 {
|
|
return clamp(v, 0, 1)
|
|
}
|
|
|
|
// abs returns the absolute value of an int and is used in raster helpers.
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
// cloneMask copies a PixelMask and is used when mutating temporary masks.
|
|
func cloneMask(src *PixelMask) *PixelMask {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
dst := NewPixelMask(src.Width, src.Height)
|
|
copy(dst.Data, src.Data)
|
|
return dst
|
|
}
|
|
|
|
// averagePoint returns the average of points and is used for centroids.
|
|
func averagePoint(points []image.Point) image.Point {
|
|
if len(points) == 0 {
|
|
return image.Point{}
|
|
}
|
|
var sx, sy int
|
|
for _, p := range points {
|
|
sx += p.X
|
|
sy += p.Y
|
|
}
|
|
return image.Point{X: sx / len(points), Y: sy / len(points)}
|
|
}
|
|
|
|
// estimateWallTangent approximates the tangent at a wall pixel and is used to align roads and gates.
|
|
func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) {
|
|
if wallMask == nil {
|
|
return 0, 0, false
|
|
}
|
|
const r = 4
|
|
var pts [][2]float64
|
|
for dy := -r; dy <= r; dy++ {
|
|
y := mid.Y + dy
|
|
if y < 0 || y >= wallMask.Height {
|
|
continue
|
|
}
|
|
for dx := -r; dx <= r; dx++ {
|
|
x := mid.X + dx
|
|
if x < 0 || x >= wallMask.Width {
|
|
continue
|
|
}
|
|
if wallMask.GetXY(x, y) {
|
|
pts = append(pts, [2]float64{float64(x), float64(y)})
|
|
}
|
|
}
|
|
}
|
|
if len(pts) < 3 {
|
|
return 0, 0, false
|
|
}
|
|
|
|
var mx, my float64
|
|
for _, p := range pts {
|
|
mx += p[0]
|
|
my += p[1]
|
|
}
|
|
mx /= float64(len(pts))
|
|
my /= float64(len(pts))
|
|
|
|
var sxx, syy, sxy float64
|
|
for _, p := range pts {
|
|
dx := p[0] - mx
|
|
dy := p[1] - my
|
|
sxx += dx * dx
|
|
syy += dy * dy
|
|
sxy += dx * dy
|
|
}
|
|
if sxx+syy < 0.001 {
|
|
return 0, 0, false
|
|
}
|
|
theta := 0.5 * math.Atan2(2*sxy, sxx-syy)
|
|
return math.Cos(theta), math.Sin(theta), true
|
|
}
|
|
|
|
type tappableImage struct {
|
|
widget.BaseWidget
|
|
image *fyne.Container
|
|
onTapped func()
|
|
}
|
|
|
|
// newTappableImage wraps a container so taps can trigger image interactions.
|
|
func newTappableImage(img *fyne.Container, tapped func()) *tappableImage {
|
|
ti := &tappableImage{
|
|
image: img,
|
|
onTapped: tapped,
|
|
}
|
|
ti.ExtendBaseWidget(ti)
|
|
return ti
|
|
}
|
|
|
|
// CreateRenderer builds the renderer for tappableImage and is used by Fyne.
|
|
func (t *tappableImage) CreateRenderer() fyne.WidgetRenderer {
|
|
return widget.NewSimpleRenderer(t.image)
|
|
}
|
|
|
|
// Tapped invokes the handler and is used for click interactions.
|
|
func (t *tappableImage) Tapped(*fyne.PointEvent) {
|
|
if t.onTapped != nil {
|
|
t.onTapped()
|
|
}
|
|
}
|
|
|
|
// numericInputSlider combines a slider and text entry for numeric input.
|
|
type numericInputSlider struct {
|
|
widget.BaseWidget
|
|
value binding.Float
|
|
min, max float64
|
|
step float64
|
|
slider *widget.Slider
|
|
entry *widget.Entry
|
|
format string
|
|
errorLabel *widget.Label
|
|
label *widget.Label
|
|
}
|
|
|
|
type numericInputSliderRenderer struct {
|
|
slider *numericInputSlider
|
|
label *widget.Label
|
|
entry *widget.Entry
|
|
sliderWidget *widget.Slider
|
|
errorLabel *widget.Label
|
|
layout fyne.Layout
|
|
objects []fyne.CanvasObject
|
|
}
|
|
|
|
func (r *numericInputSliderRenderer) MinSize() fyne.Size {
|
|
return r.layout.MinSize(r.objects)
|
|
}
|
|
|
|
func (r *numericInputSliderRenderer) Layout(size fyne.Size) {
|
|
r.layout.Layout(r.objects, size)
|
|
}
|
|
|
|
func (r *numericInputSliderRenderer) Objects() []fyne.CanvasObject {
|
|
return r.objects
|
|
}
|
|
|
|
func (r *numericInputSliderRenderer) Refresh() {
|
|
r.label.SetText(r.slider.label.Text)
|
|
}
|
|
|
|
func (r *numericInputSliderRenderer) Destroy() {}
|
|
|
|
// newNumericInputSlider creates a slider+entry pair for numeric settings.
|
|
func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider {
|
|
s := &numericInputSlider{
|
|
min: min,
|
|
max: max,
|
|
format: format,
|
|
step: 0,
|
|
}
|
|
s.ExtendBaseWidget(s)
|
|
|
|
s.label = widget.NewLabel(labelText)
|
|
s.value = binding.NewFloat()
|
|
s.value.Set(initialValue)
|
|
|
|
s.slider = widget.NewSlider(min, max)
|
|
s.slider.Bind(s.value)
|
|
|
|
s.entry = widget.NewEntry()
|
|
s.value.AddListener(binding.NewDataListener(func() {
|
|
val, _ := s.value.Get()
|
|
s.entry.SetText(fmt.Sprintf(s.format, val))
|
|
}))
|
|
|
|
s.errorLabel = widget.NewLabel("")
|
|
s.errorLabel.Hide()
|
|
|
|
return s
|
|
}
|
|
|
|
// newNumericInputSliderWithStep creates a numeric slider that snaps to increments.
|
|
func newNumericInputSliderWithStep(min, max, initialValue, step float64, format string, labelText string) *numericInputSlider {
|
|
s := newNumericInputSlider(min, max, initialValue, format, labelText)
|
|
if step > 0 {
|
|
s.step = step
|
|
s.slider.Step = step
|
|
rounded := min + math.Round((initialValue-min)/step)*step
|
|
s.value.Set(rounded)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// validate checks entry input and is used for slider text validation.
|
|
func (s *numericInputSlider) validate(text string, onError func(bool)) {
|
|
text = strings.TrimSpace(text)
|
|
text = strings.TrimSuffix(text, "px")
|
|
text = strings.TrimSuffix(text, "%")
|
|
text = strings.TrimSuffix(text, "°")
|
|
text = strings.TrimSpace(text)
|
|
val, err := strconv.ParseFloat(text, 64)
|
|
if err != nil {
|
|
s.errorLabel.SetText("Not a number")
|
|
s.errorLabel.Show()
|
|
onError(true)
|
|
return
|
|
}
|
|
|
|
if val < s.min || val > s.max {
|
|
s.errorLabel.SetText(fmt.Sprintf(
|
|
"Out of range (%s-%s)",
|
|
strconv.FormatFloat(s.min, 'f', -1, 64),
|
|
strconv.FormatFloat(s.max, 'f', -1, 64),
|
|
))
|
|
s.errorLabel.Show()
|
|
onError(true)
|
|
return
|
|
}
|
|
if s.step > 0 {
|
|
steps := math.Round((val - s.min) / s.step)
|
|
snapped := s.min + steps*s.step
|
|
if math.Abs(val-snapped) > 1e-9 {
|
|
s.errorLabel.SetText(fmt.Sprintf("Use increments of %s", strconv.FormatFloat(s.step, 'f', -1, 64)))
|
|
s.errorLabel.Show()
|
|
onError(true)
|
|
return
|
|
}
|
|
val = snapped
|
|
}
|
|
|
|
s.errorLabel.Hide()
|
|
onError(false)
|
|
s.value.Set(val)
|
|
}
|
|
|
|
// CreateRenderer builds the widget layout and is used by Fyne.
|
|
func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer {
|
|
r := &numericInputSliderRenderer{
|
|
slider: s,
|
|
label: s.label,
|
|
entry: s.entry,
|
|
sliderWidget: s.slider,
|
|
errorLabel: s.errorLabel,
|
|
}
|
|
|
|
r.layout = layout.NewGridLayout(1)
|
|
r.objects = []fyne.CanvasObject{
|
|
container.NewGridWithColumns(2, r.label, r.entry),
|
|
r.sliderWidget,
|
|
r.errorLabel,
|
|
}
|
|
|
|
return r
|
|
}
|