package web // Dungeon map rendering. // // gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap): // every room they have visited with its true kind, plus the one-hop frontier of // doors leading out of visited rooms, with the rooms behind them withheld as // kind "unknown". Pete lays that graph out and draws it as an inline SVG on the // who page. No node labels or contents ever cross the wire — the map knows a // room is a trap, never what the trap is. // // Layout is a left-to-right layering: column = BFS depth from the entry along // the edges we were given, row = order the node first appears in the payload. // It is computed server-side and fully deterministic, so the same run draws the // same map every render. // Layout geometry. The node/ring radii live in who.html and the map CSS; only // the spacing the layout math needs is here. const ( mapColGap = 84 // horizontal spacing between depth columns mapRowGap = 60 // vertical spacing between nodes sharing a column mapMargin = 26 // padding around the node field ) // mapView is the laid-out map, ready for the template's SVG. type mapView struct { Nodes []mapNode Edges []mapEdge W int // svg viewBox width H int // svg viewBox height } type mapNode struct { X, Y int Kind string // ZoneNodeKind, or "unknown" for an unreached frontier room Glyph string // the single character drawn inside the node Class string // CSS class picking the node's colour family Label string // accessible title, e.g. "Trap" or "Unexplored" Current bool // the room the adventurer is standing in } type mapEdge struct { X1, Y1, X2, Y2 int Locked bool Lock string // the gate's kind, for the door's title text } // nodeStyle maps a room kind to how it draws. Kept small and total: an unknown // kind (a room type Pete hasn't been taught yet) falls through to a neutral dot // rather than vanishing. type nodeStyle struct { glyph string class string label string } var mapNodeStyles = map[string]nodeStyle{ "entry": {"⌂", "map-node-entry", "Entrance"}, "exploration": {"·", "map-node-plain", "Room"}, "trap": {"!", "map-node-trap", "Trap"}, "elite": {"◆", "map-node-elite", "Elite"}, "boss": {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu "harvest": {"❖", "map-node-harvest", "Harvest"}, "rest_camp": {"✚", "map-node-rest", "Rest camp"}, "secret": {"✦", "map-node-secret", "Secret"}, "fork": {"⋔", "map-node-plain", "Fork"}, "merge": {"⋏", "map-node-plain", "Merge"}, "unknown": {"?", "map-node-unknown", "Unexplored"}, } func styleFor(kind string) nodeStyle { if s, ok := mapNodeStyles[kind]; ok { return s } return nodeStyle{"·", "map-node-plain", "Room"} } // buildMapView lays out a fog-of-war graph. Returns nil when there is nothing // worth drawing (no nodes) so the template can skip the panel entirely. func buildMapView(m *whoMap) *mapView { if m == nil || len(m.Nodes) == 0 { return nil } // Adjacency for the depth walk, and appearance order for stable rows. order := make(map[string]int, len(m.Nodes)) for i, n := range m.Nodes { if _, dup := order[n.ID]; !dup { order[n.ID] = i } } adj := make(map[string][]string, len(m.Nodes)) for _, e := range m.Edges { // Only lay out edges whose endpoints are both nodes we were given; a // half-edge would point at nothing. if _, ok := order[e.From]; !ok { continue } if _, ok := order[e.To]; !ok { continue } adj[e.From] = append(adj[e.From], e.To) } // Depth = shortest hop count from the entry. The first visited node is the // entry (gogobee sends Visited in path order); fall back to appearance // order 0 if Visited is somehow empty. start := "" if len(m.Visited) > 0 { start = m.Visited[0] } else { start = m.Nodes[0].ID } depth := bfsDepth(start, adj, order) // Group nodes by depth column, each column ordered by first appearance. maxDepth := 0 byCol := map[int][]string{} placed := make(map[string]bool, len(m.Nodes)) for _, n := range m.Nodes { // Two visited rooms can both list a door to the same withheld frontier // room, so the same id can appear twice in Nodes. Place it once, or it // draws as two overlapping discs and its edges resolve to whichever copy // landed last. if placed[n.ID] { continue } placed[n.ID] = true d := depth[n.ID] byCol[d] = append(byCol[d], n.ID) if d > maxDepth { maxDepth = d } } maxRows := 0 for d := 0; d <= maxDepth; d++ { sortByOrder(byCol[d], order) if len(byCol[d]) > maxRows { maxRows = len(byCol[d]) } } kindByID := make(map[string]string, len(m.Nodes)) for _, n := range m.Nodes { kindByID[n.ID] = n.Kind } // Place nodes. Each column is vertically centred against the tallest column // so the map reads as balanced rather than top-aligned. pos := make(map[string][2]int, len(m.Nodes)) view := &mapView{ W: mapMargin*2 + maxDepth*mapColGap, H: mapMargin*2 + max(0, maxRows-1)*mapRowGap, } if view.W < mapMargin*2 { view.W = mapMargin * 2 } for d := 0; d <= maxDepth; d++ { col := byCol[d] x := mapMargin + d*mapColGap offset := (maxRows - len(col)) * mapRowGap / 2 for i, id := range col { y := mapMargin + offset + i*mapRowGap pos[id] = [2]int{x, y} } } for d := 0; d <= maxDepth; d++ { for _, id := range byCol[d] { p := pos[id] st := styleFor(kindByID[id]) view.Nodes = append(view.Nodes, mapNode{ X: p[0], Y: p[1], Kind: kindByID[id], Glyph: st.glyph, Class: st.class, Label: st.label, Current: id == m.CurrentNode, }) } } for _, e := range m.Edges { a, okA := pos[e.From] b, okB := pos[e.To] if !okA || !okB { continue } view.Edges = append(view.Edges, mapEdge{ X1: a[0], Y1: a[1], X2: b[0], Y2: b[1], Locked: e.Lock != "" && e.Lock != "none", Lock: e.Lock, }) } return view } // bfsDepth returns the hop distance from start to every node reachable along // adj. Nodes never reached (there should be none in a well-formed cut) default // to 0, so a stray node still lands in the first column rather than off-canvas. func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int { depth := map[string]int{start: 0} queue := []string{start} for len(queue) > 0 { u := queue[0] queue = queue[1:] for _, v := range adj[u] { if _, seen := depth[v]; !seen { depth[v] = depth[u] + 1 queue = append(queue, v) } } } // Any node with an appearance order but no depth (unreachable via the cut) // gets 0 so it is still placed. for id := range order { if _, ok := depth[id]; !ok { depth[id] = 0 } } return depth } // sortByOrder sorts ids in place by their first-appearance index. Insertion // sort — columns hold a handful of nodes, and it keeps the ordering stable // without pulling in a comparator closure. func sortByOrder(ids []string, order map[string]int) { for i := 1; i < len(ids); i++ { for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- { ids[j], ids[j-1] = ids[j-1], ids[j] } } }