feat: add Number Connect game with ABC spatial optimization and auto-pagination
Build and Push Docker Image / build (push) Successful in 3m45s
Build and Push Docker Image / build (push) Successful in 3m45s
This commit is contained in:
Binary file not shown.
@@ -10,6 +10,9 @@ import (
|
||||
//go:embed fruit.svg
|
||||
var fruitSVGContent []byte
|
||||
|
||||
//go:embed font.ttf
|
||||
var FontContent []byte
|
||||
|
||||
type Icon struct {
|
||||
Name string
|
||||
Paths []string
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"toolbox/pkg/learnnumber/data"
|
||||
|
||||
"github.com/signintech/gopdf"
|
||||
)
|
||||
|
||||
type WritingRequest struct {
|
||||
StartNum int `json:"start_num"`
|
||||
EndNum int `json:"end_num"`
|
||||
PaperSize string `json:"paper_size"`
|
||||
}
|
||||
|
||||
type WritingNode struct {
|
||||
X, Y, R float64
|
||||
AngleA, AngleB float64
|
||||
BulgeR, Dist float64
|
||||
Number int
|
||||
}
|
||||
|
||||
func GenerateWritingPDF(req WritingRequest) ([]byte, error) {
|
||||
pdf := &gopdf.GoPdf{}
|
||||
rect := gopdf.Rect{W: 595.28, H: 841.89}
|
||||
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
|
||||
pdf.Start(gopdf.Config{PageSize: rect})
|
||||
|
||||
err := pdf.AddTTFFontData("basic", data.FontContent)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
currentNum := req.StartNum
|
||||
// 只要还没达到 EndNum,就继续生成“整页”内容
|
||||
for currentNum <= req.EndNum {
|
||||
pdf.AddPage()
|
||||
lastPlaced := drawOneFullPage(pdf, currentNum, rect.W, rect.H)
|
||||
currentNum = lastPlaced + 1
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = pdf.WriteTo(&buf)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// 核心改变:不再受限于 end 参数,而是尝试填满整页
|
||||
func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
|
||||
margin := 80.0
|
||||
boxW, boxH := pW-2*margin, pH-160.0
|
||||
xBase, yBase := margin, 100.0
|
||||
|
||||
nodeR := 28.0
|
||||
bulgeR := nodeR * 0.32
|
||||
dist := nodeR + bulgeR + 18.0
|
||||
checkR := nodeR * 1.3
|
||||
|
||||
var nodes []WritingNode
|
||||
num := start
|
||||
|
||||
// 无限循环,直到页面塞不下为止
|
||||
for {
|
||||
placed := false
|
||||
for retry := 0; retry < 1000; retry++ {
|
||||
rx := xBase + dist + 20 + rand.Float64()*(boxW - 2*dist - 40)
|
||||
ry := yBase + dist + 20 + rand.Float64()*(boxH - 2*dist - 40)
|
||||
|
||||
if isColliding(rx, ry, checkR, nodes) { continue }
|
||||
|
||||
angleA := rand.Float64() * 2 * math.Pi
|
||||
angleB := angleA + math.Pi + (rand.Float64()-0.5)*1.5
|
||||
nodes = append(nodes, WritingNode{X: rx, Y: ry, R: nodeR, AngleA: angleA, AngleB: angleB, BulgeR: bulgeR, Dist: dist, Number: num})
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
|
||||
if !placed {
|
||||
// 页面已满
|
||||
break
|
||||
}
|
||||
num++
|
||||
}
|
||||
|
||||
// 空间优化
|
||||
optimizeSpace(nodes, xBase, yBase, boxW, boxH, dist, checkR)
|
||||
|
||||
// 绘制
|
||||
for _, n := range nodes {
|
||||
drawFullNode(pdf, n)
|
||||
}
|
||||
|
||||
// 返回本页最后一个数字
|
||||
if len(nodes) > 0 {
|
||||
return nodes[len(nodes)-1].Number
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR float64) {
|
||||
if len(nodes) < 2 { return }
|
||||
for iter := 0; iter < 120; iter++ {
|
||||
for i := range nodes {
|
||||
bestX, bestY := nodes[i].X, nodes[i].Y
|
||||
maxMinDist := calcMinDist(bestX, bestY, i, nodes)
|
||||
for k := 0; k < 8; k++ {
|
||||
moveAngle := rand.Float64() * 2 * math.Pi
|
||||
nx := nodes[i].X + math.Cos(moveAngle)*5.0
|
||||
ny := nodes[i].Y + math.Sin(moveAngle)*5.0
|
||||
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH { continue }
|
||||
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) { continue }
|
||||
newMinDist := calcMinDist(nx, ny, i, nodes)
|
||||
if newMinDist > maxMinDist {
|
||||
maxMinDist = newMinDist; bestX, bestY = nx, ny
|
||||
}
|
||||
}
|
||||
nodes[i].X, nodes[i].Y = bestX, bestY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isColliding(x, y, r float64, existing []WritingNode) bool {
|
||||
for _, e := range existing {
|
||||
d := math.Sqrt(math.Pow(x-e.X, 2) + math.Pow(y-e.Y, 2))
|
||||
if d < (r + e.R*1.3 + 30.0) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
|
||||
minD := 10000.0
|
||||
for i, n := range nodes {
|
||||
if i == idx { continue }
|
||||
d := math.Sqrt(math.Pow(x-n.X, 2) + math.Pow(y-n.Y, 2))
|
||||
if d < minD { minD = d }
|
||||
}
|
||||
return minD
|
||||
}
|
||||
|
||||
func getEdgePoint(x, y, r, angle float64, isCircle bool) (float64, float64) {
|
||||
gap := 2.0
|
||||
er := r + gap
|
||||
if isCircle { return x + math.Cos(angle)*er, y + math.Sin(angle)*er }
|
||||
absCos, absSin := math.Abs(math.Cos(angle)), math.Abs(math.Sin(angle))
|
||||
var d float64
|
||||
if absCos > absSin { d = er / absCos } else { d = er / absSin }
|
||||
return x + math.Cos(angle)*d, y + math.Sin(angle)*d
|
||||
}
|
||||
|
||||
func drawCenteredText(pdf *gopdf.GoPdf, cx, cy float64, text string, fontSize float64) {
|
||||
pdf.SetFont("basic", "", fontSize)
|
||||
tw, _ := pdf.MeasureTextWidth(text)
|
||||
pdf.SetXY(cx - tw/2, cy + fontSize*0.38)
|
||||
pdf.Text(text)
|
||||
}
|
||||
|
||||
func drawFullNode(pdf *gopdf.GoPdf, n WritingNode) {
|
||||
isOdd := n.Number%2 != 0
|
||||
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.8)
|
||||
if isOdd { pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R) } else { pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2) }
|
||||
pdf.SetTextColor(220, 220, 220)
|
||||
drawCenteredText(pdf, n.X, n.Y, strconv.Itoa(n.Number), n.R*1.35)
|
||||
|
||||
pdf.SetStrokeColor(180, 180, 180); pdf.SetLineWidth(1.0)
|
||||
ax, ay := n.X + math.Cos(n.AngleA)*n.Dist, n.Y + math.Sin(n.AngleA)*n.Dist
|
||||
lx1, ly1 := getEdgePoint(n.X, n.Y, n.R, n.AngleA, isOdd)
|
||||
lx2, ly2 := getEdgePoint(ax, ay, n.BulgeR, n.AngleA+math.Pi, !isOdd)
|
||||
pdf.Line(lx1, ly1, lx2, ly2)
|
||||
if isOdd { pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR) }
|
||||
|
||||
bx, by := n.X + math.Cos(n.AngleB)*n.Dist, n.Y + math.Sin(n.AngleB)*n.Dist
|
||||
lx3, ly3 := getEdgePoint(n.X, n.Y, n.R, n.AngleB, isOdd)
|
||||
lx4, ly4 := getEdgePoint(bx, by, n.BulgeR, n.AngleB+math.Pi, !isOdd)
|
||||
pdf.Line(lx3, ly3, lx4, ly4)
|
||||
if isOdd { pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR) }
|
||||
|
||||
pdf.SetTextColor(120, 120, 120)
|
||||
drawCenteredText(pdf, bx, by, strconv.Itoa(n.Number+1), math.Max(7, n.BulgeR*1.2))
|
||||
}
|
||||
@@ -29,9 +29,24 @@ func (t *learnNumberTool) Init() error {
|
||||
|
||||
func (t *learnNumberTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/counting", t.handleCounting)
|
||||
r.POST("/writing", t.handleWriting)
|
||||
r.GET("/categories", t.handleCategories)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleWriting(c *gin.Context) {
|
||||
var req logic.WritingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pdfBytes, err := logic.GenerateWritingPDF(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
func (t *learnNumberTool) handleCategories(c *gin.Context) {
|
||||
// 返回分类信息供前端预览
|
||||
c.JSON(http.StatusOK, data.IconCategories)
|
||||
|
||||
Reference in New Issue
Block a user