adventure: draw the dungeon as a fog-of-war map, not a room tally
This commit is contained in:
223
internal/web/who_map.go
Normal file
223
internal/web/who_map.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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{}
|
||||
for _, n := range m.Nodes {
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user