Files
RPG_City_Maker_Reborn/tools.go
T
2026-03-12 12:22:09 -05:00

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
}