maze.gno
13.09 Kb · 505 lines
1// This package demonstrate the capability of gno to build dynamic svg image
2// based on different query parameters.
3// Raycasting implementation as been heavily inspired by this project: https://github.com/AZHenley/raycasting
4
5package gnomaze
6
7import (
8 "chain/runtime"
9 "chain/runtime/unsafe"
10 "encoding/base64"
11 "hash/adler32"
12 "math"
13 "math/rand"
14 "net/url"
15 "strconv"
16 "strings"
17 "time"
18
19 "gno.land/p/moul/txlink"
20)
21
22const baseLevel = 7
23
24// Constants for cell dimensions
25const (
26 cellSize = 1.0
27 halfCell = cellSize / 2
28)
29
30type CellKind int
31
32const (
33 CellKindEmpty = iota
34 CellKindWall
35)
36
37var (
38 level int = 1
39 salt int64
40 maze [][]int
41 endPos, startPos Position
42)
43
44func init() {
45 // Generate the map
46 seed := uint64(runtime.ChainHeight())
47 rng := rand.New(rand.NewPCG(seed, uint64(time.Now().Unix())))
48 generateLevel(rng, level)
49 salt = rng.Int64()
50}
51
52// Position represents the X, Y coordinates in the maze
53type Position struct{ X, Y int }
54
55// Player represents a player with position and viewing angle
56type Player struct {
57 X, Y, Angle, FOV float64
58}
59
60// PlayerState holds the player's grid position and direction
61type PlayerState struct {
62 CellX int // Grid X position
63 CellY int // Grid Y position
64 Direction int // 0-7 (0 = east, 1 = SE, 2 = S, etc.)
65}
66
67// Angle calculates the direction angle in radians
68func (p *PlayerState) Angle() float64 {
69 return float64(p.Direction) * math.Pi / 4
70}
71
72// Position returns the player's exact position in the grid
73func (p *PlayerState) Position() (float64, float64) {
74 return float64(p.CellX) + halfCell, float64(p.CellY) + halfCell
75}
76
77// SumCode returns a hash string based on the player's position
78func (p *PlayerState) SumCode() string {
79 a := adler32.New()
80
81 var width int
82 if len(maze) > 0 {
83 width = len(maze[0])
84 }
85
86 s := strconv.Itoa(p.CellY*width+p.CellX) + "-" + strconv.Itoa(level) + "-" + strconv.FormatInt(salt, 10)
87 a.Write([]byte(s))
88 return strconv.FormatUint(uint64(a.Sum32()), 10)
89}
90
91// Move updates the player's position based on movement deltas
92func (p *PlayerState) Move(dx, dy int) {
93 newX := p.CellX + dx
94 newY := p.CellY + dy
95
96 if newY >= 0 && newY < len(maze) && newX >= 0 && newX < len(maze[0]) {
97 if maze[newY][newX] == 0 {
98 p.CellX = newX
99 p.CellY = newY
100 }
101 }
102}
103
104// Rotate changes the player's direction
105func (p *PlayerState) Rotate(clockwise bool) {
106 if clockwise {
107 p.Direction = (p.Direction + 1) % 8
108 } else {
109 p.Direction = (p.Direction + 7) % 8
110 }
111}
112
113// GenerateNextLevel validates the answer and generates a new level
114func GenerateNextLevel(cur realm, answer string) {
115 seed := uint64(runtime.ChainHeight())
116 rng := rand.New(rand.NewPCG(seed, uint64(time.Now().Unix())))
117
118 endState := PlayerState{CellX: endPos.X, CellY: endPos.Y}
119 hash := endState.SumCode()
120 if hash != answer {
121 panic("invalid answer")
122 }
123
124 // Generate new map
125 level++
126 salt = rng.Int64()
127 generateLevel(rng, level)
128}
129
130// generateLevel creates a new maze for the given level
131func generateLevel(rng *rand.Rand, level int) {
132 if level < 0 {
133 panic("invalid level")
134 }
135
136 size := level + baseLevel
137 maze, startPos, endPos = generateMap(rng, size, size)
138}
139
140// generateMap creates a random maze using a depth-first search algorithm.
141func generateMap(rng *rand.Rand, width, height int) ([][]int, Position, Position) {
142 // Initialize the maze grid filled with walls.
143 m := make([][]int, height)
144 for y := range m {
145 m[y] = make([]int, width)
146 for x := range m[y] {
147 m[y][x] = CellKindWall
148 }
149 }
150
151 // Define start position and initialize stack for DFS
152 start := Position{1, 1}
153 stack := []Position{start}
154 m[start.Y][start.X] = CellKindEmpty
155
156 // Initialize distance matrix and track farthest
157 dist := make([][]int, height)
158 for y := range dist {
159 dist[y] = make([]int, width)
160 for x := range dist[y] {
161 dist[y][x] = -1
162 }
163 }
164 dist[start.Y][start.X] = CellKindEmpty
165 maxDist := 0
166 candidates := []Position{start}
167
168 // Possible directions for movement: right, left, down, up
169 directions := []Position{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}
170
171 // Generate maze paths using DFS
172 for len(stack) > 0 {
173 current := stack[len(stack)-1]
174 stack = stack[:len(stack)-1]
175
176 var dirCandidates []struct {
177 next, wall Position
178 }
179
180 // Evaluate possible candidates for maze paths
181 for _, d := range directions {
182 nx, ny := current.X+d.X*2, current.Y+d.Y*2
183 wx, wy := current.X+d.X, current.Y+d.Y
184
185 // Check if the candidate position is within bounds and still a wall
186 if nx > 0 && nx < width-1 && ny > 0 && ny < height-1 && m[ny][nx] == 1 {
187 dirCandidates = append(dirCandidates, struct{ next, wall Position }{
188 Position{nx, ny}, Position{wx, wy},
189 })
190 }
191 }
192
193 // If candidates are available, choose one and update the maze
194 if len(dirCandidates) > 0 {
195 chosen := dirCandidates[rng.IntN(len(dirCandidates))]
196 m[chosen.wall.Y][chosen.wall.X] = CellKindEmpty
197 m[chosen.next.Y][chosen.next.X] = CellKindEmpty
198
199 // Update distance for the next cell
200 currentDist := dist[current.Y][current.X]
201 nextDist := currentDist + 2
202 dist[chosen.next.Y][chosen.next.X] = nextDist
203
204 // Update maxDist and candidates
205 if nextDist > maxDist {
206 maxDist = nextDist
207 candidates = []Position{chosen.next}
208 } else if nextDist == maxDist {
209 candidates = append(candidates, chosen.next)
210 }
211
212 stack = append(stack, current, chosen.next)
213 }
214 }
215
216 // Select a random farthest position as the end
217 var end Position
218 if len(candidates) > 0 {
219 end = candidates[rng.IntN(len(candidates))]
220 } else {
221 end = Position{width - 2, height - 2} // Fallback to bottom-right
222 }
223
224 return m, start, end
225}
226
227// ftoa formats a float for SVG attribute values
228func ftoa(f float64) string {
229 return strconv.FormatFloat(f, 'f', 6, 64)
230}
231
232// castRay simulates a ray casting in the maze to find walls
233func castRay(playerX, playerY, rayAngle float64, m [][]int) (distance float64, wallHeight float64, endCellHit bool, endDistance float64) {
234 x, y := playerX, playerY
235 dx, dy := math.Cos(rayAngle), math.Sin(rayAngle)
236 steps := 0
237 endCellHit = false
238 endDistance = 0.0
239
240 for {
241 ix, iy := int(math.Floor(x)), int(math.Floor(y))
242 if ix == endPos.X && iy == endPos.Y {
243 endCellHit = true
244 endDistance = math.Sqrt(math.Pow(x-playerX, 2) + math.Pow(y-playerY, 2))
245 }
246
247 if iy < 0 || iy >= len(m) || ix < 0 || ix >= len(m[0]) || m[iy][ix] != 0 {
248 break
249 }
250
251 x += dx * 0.1
252 y += dy * 0.1
253 steps++
254 if steps > 400 {
255 break
256 }
257 }
258
259 distance = math.Sqrt(math.Pow(x-playerX, 2) + math.Pow(y-playerY, 2))
260 wallHeight = 300.0 / distance
261 return
262}
263
264// GenerateSVG creates an SVG representation of the maze scene
265func GenerateSVG(p *PlayerState) string {
266 const (
267 svgWidth, svgHeight = 800, 600
268 offsetX, offsetY = 0.0, 500.0
269 groundLevel = 300
270 rays = 124
271 fov = math.Pi / 4
272 miniMapSize = 100.0
273 visibleCells = 7
274 dirLen = 2.0
275 )
276
277 m := maze
278 playerX, playerY := p.Position()
279 angle := p.Angle()
280
281 sliceWidth := float64(svgWidth) / float64(rays)
282 angleStep := fov / float64(rays)
283
284 var svg strings.Builder
285 svg.WriteString(`<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">`)
286 svg.WriteString(`<rect x="0" y="0" width="800" height="300" fill="rgb(20,40,20)"/>`)
287 svg.WriteString(`<rect x="0" y="300" width="800" height="300" fill="rgb(40,60,40)"/>`)
288
289 var drawBanana func()
290 for i := 0; i < rays; i++ {
291 rayAngle := angle - fov/2 + float64(i)*angleStep
292 distance, wallHeight, endHit, endDist := castRay(playerX, playerY, rayAngle, m)
293 darkness := 1.0 + distance/4.0
294 colorVal1 := int(180.0 / darkness)
295 colorVal2 := int(32.0 / darkness)
296 yPos := groundLevel - wallHeight/2
297
298 svg.WriteString(`<rect x="` + ftoa(float64(i)*sliceWidth) +
299 `" y="` + ftoa(yPos) +
300 `" width="` + ftoa(sliceWidth) +
301 `" height="` + ftoa(wallHeight) +
302 `" fill="rgb(` + strconv.Itoa(colorVal1) + `,69,` + strconv.Itoa(colorVal2) + `)"/>`)
303
304 if drawBanana != nil {
305 continue // Banana already drawn
306 }
307
308 // Only draw banana if the middle ray hit the end
309 // XXX: improve this by checking for a hit in the middle of the end cell
310 if i == rays/2 && endHit && endDist < distance {
311 iconHeight := 10.0 / endDist
312 scale := iconHeight / 100
313 x := float64(i)*sliceWidth + sliceWidth/2
314 y := groundLevel + 20 + (iconHeight*scale)/2
315
316 drawBanana = func() {
317 svg.WriteString(`<g transform="translate(` + ftoa(x) + ` ` + ftoa(y) +
318 `) scale(` + ftoa(scale) + `)">` + string(svgassets["banana"]) + `</g>`)
319 }
320 }
321 }
322
323 if drawBanana != nil {
324 drawBanana()
325 }
326
327 playerCellX, playerCellY := int(math.Floor(playerX)), int(math.Floor(playerY))
328
329 xStart := max(0, playerCellX-visibleCells/2)
330 xEnd := min(len(m[0]), playerCellX+visibleCells/2+1)
331
332 yStart := max(0, playerCellY-visibleCells/2)
333 yEnd := min(len(m), playerCellY+visibleCells/2+1)
334
335 scaleX := miniMapSize / float64(xEnd-xStart)
336 scaleY := miniMapSize / float64(yEnd-yStart)
337
338 for y := yStart; y < yEnd; y++ {
339 for x := xStart; x < xEnd; x++ {
340 color := "black"
341 if m[y][x] == 1 {
342 color = "rgb(149,0,32)"
343 }
344 svg.WriteString(`<rect x="` + ftoa(float64(x-xStart)*scaleX+offsetX) +
345 `" y="` + ftoa(float64(y-yStart)*scaleY+offsetY) +
346 `" width="` + ftoa(scaleX) +
347 `" height="` + ftoa(scaleY) +
348 `" fill="` + color + `"/>`)
349 }
350 }
351
352 px := (playerX-float64(xStart))*scaleX + offsetX
353 py := (playerY-float64(yStart))*scaleY + offsetY
354 svg.WriteString(`<circle cx="` + ftoa(px) + `" cy="` + ftoa(py) + `" r="` + ftoa(scaleX/2) + `" fill="rgb(200,200,200)"/>`)
355
356 dx := math.Cos(angle) * dirLen
357 dy := math.Sin(angle) * dirLen
358 svg.WriteString(`<line x1="` + ftoa(px) + `" y1="` + ftoa(py) +
359 `" x2="` + ftoa((playerX+dx-float64(xStart))*scaleX+offsetX) +
360 `" y2="` + ftoa((playerY+dy-float64(yStart))*scaleY+offsetY) +
361 `" stroke="rgb(200,200,200)" stroke-width="1"/>`)
362
363 svg.WriteString(`</svg>`)
364 return svg.String()
365}
366
367// renderGrid3D creates a 3D view of the grid
368func renderGrid3D(p *PlayerState) string {
369 svg := GenerateSVG(p)
370 base64SVG := base64.StdEncoding.EncodeToString([]byte(svg))
371 return ""
372}
373
374// generateDirLink generates a link to change player direction
375func generateDirLink(path string, p *PlayerState, action string) string {
376 newState := *p // Make copy
377
378 switch action {
379 case "forward":
380 dx, dy := directionDeltas(newState.Direction)
381 newState.Move(dx, dy)
382 case "left":
383 newState.Rotate(false)
384 case "right":
385 newState.Rotate(true)
386 }
387
388 vals := make(url.Values)
389 vals.Set("x", strconv.Itoa(newState.CellX))
390 vals.Set("y", strconv.Itoa(newState.CellY))
391 vals.Set("dir", strconv.Itoa(newState.Direction))
392
393 vals.Set("sum", newState.SumCode())
394 return path + "?" + vals.Encode()
395}
396
397// isPlayerTouchingWall checks if the player's position is inside a wall
398func isPlayerTouchingWall(x, y float64) bool {
399 ix, iy := int(math.Floor(x)), int(math.Floor(y))
400 if iy < 0 || iy >= len(maze) || ix < 0 || ix >= len(maze[0]) {
401 return true
402 }
403 return maze[iy][ix] == CellKindEmpty
404}
405
406// directionDeltas provides deltas for movement based on direction
407func directionDeltas(d int) (x, y int) {
408 s := []struct{ x, y int }{
409 {1, 0}, // 0 == E
410 {1, 1}, // SE
411 {0, 1}, // S
412 {-1, 1}, // SW
413 {-1, 0}, // W
414 {-1, -1}, // NW
415 {0, -1}, // N
416 {1, -1}, // NE
417 }[d]
418 return s.x, s.y
419}
420
421// atoiDefault converts string to integer with a default fallback
422func atoiDefault(s string, def int) int {
423 if s == "" {
424 return def
425 }
426 i, _ := strconv.Atoi(s)
427 return i
428}
429
430// Render renders the game interface
431func Render(path string) string {
432 u, _ := url.Parse(path)
433 query := u.Query()
434
435 p := PlayerState{
436 CellX: atoiDefault(query.Get("x"), startPos.X),
437 CellY: atoiDefault(query.Get("y"), startPos.Y),
438 Direction: atoiDefault(query.Get("dir"), 0), // Start facing east
439 }
440
441 cpath := strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain())
442 psum := p.SumCode()
443 reset := "[reset](" + cpath + ")"
444
445 if startPos.X != p.CellX || startPos.Y != p.CellY {
446 if sum := query.Get("sum"); psum != sum {
447 return "invalid sum : " + reset
448 }
449 }
450
451 if endPos.X == p.CellX && endPos.Y == p.CellY {
452 return strings.Join([]string{
453 "### Congrats you win level " + strconv.Itoa(level) + " !!",
454 "Code for next level is: " + psum,
455 "[Generate Next Level: " + strconv.Itoa(level+1) + "](" + txlink.Call("GenerateNextLevel", "answer", psum) + ")",
456 }, "\n\n")
457 }
458
459 // Generate commands
460 commands := strings.Join([]string{
461 "<gno-columns>",
462 "|||",
463 "[▲](" + generateDirLink(cpath, &p, "forward") + ")",
464 "|||",
465 "</gno-columns>",
466 "<gno-columns>",
467 "[◄](" + generateDirLink(cpath, &p, "left") + ")",
468 "|||",
469 "|||",
470 "[►](" + generateDirLink(cpath, &p, "right") + ")",
471 "</gno-columns>",
472 }, "\n\n")
473
474 // Generate view
475 view := strings.Join([]string{
476 "<gno-columns>",
477 renderGrid3D(&p),
478 "</gno-columns>",
479 }, "\n\n")
480
481 return strings.Join([]string{
482 "## Find the banana: Level " + strconv.Itoa(level),
483 "---", view, "---", commands, "---",
484 reset,
485 "Position: (" + strconv.Itoa(p.CellX) + ", " + strconv.Itoa(p.CellY) + ") Direction: " + ftoa(float64(p.Direction)/math.Pi) + "π",
486 }, "\n\n")
487}
488
489// max returns the maximum of two integers
490func max(a, b int) int {
491 if a > b {
492 return a
493 }
494 return b
495}
496
497// min returns the minimum of two integers
498func min(a, b int) int {
499 if a < b {
500 return a
501 }
502 return b
503}
504
505