Search Apps Documentation Source Content File Folder Download Copy Actions Download

maze.gno

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