From 988b006a5bb9edb6465653566e7bdf8175347b8c Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 27 Jun 2026 16:50:41 -0400 Subject: feat: one-way map links, blocked paths on map, map grid startup validation, user colors for maps --- internal/game/render_map.go | 179 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 151 insertions(+), 28 deletions(-) (limited to 'internal/game/render_map.go') diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 2bac749..049db06 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -16,6 +16,7 @@ type mapGlyphs struct { topFill rune connectorH, connectorV rune upArrow, downArrow rune + leftArrow, rightArrow rune } func mapGlyphsForPlayer(unicode bool) mapGlyphs { @@ -23,13 +24,13 @@ func mapGlyphsForPlayer(unicode bool) mapGlyphs { return mapGlyphs{ topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', side: '║', topFill: '═', connectorH: '-', connectorV: '│', - upArrow: '↑', downArrow: '↓', + upArrow: '↑', downArrow: '↓', leftArrow: '←', rightArrow: '→', } } return mapGlyphs{ topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':', side: ':', topFill: '.', connectorH: '-', connectorV: '|', - upArrow: '^', downArrow: 'v', + upArrow: '^', downArrow: 'v', leftArrow: '<', rightArrow: '>', } } @@ -129,6 +130,10 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) dimSpec := resolveDim(g, sess) + ctx := &mapRenderCtx{ + g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + } grid := make([][]mapCell, 5) for i := range grid { @@ -170,11 +175,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string continue } if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) { - connSpec := color.NoColor() - if visited != nil && (!visited[leftRoom] || !visited[rightRoom]) { - connSpec = dimSpec - } - grid[(y+1)*2][(x+1)*2+1] = mapCell{char: mg.connectorH, spec: connSpec} + grid[(y+1)*2][(x+1)*2+1] = ctx.connectorCell(leftRoom, rightRoom, world.East, world.West) } } } @@ -189,11 +190,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string continue } if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) { - connSpec := color.NoColor() - if visited != nil && (!visited[topRoom] || !visited[bottomRoom]) { - connSpec = dimSpec - } - grid[(y+1)*2+1][(x+1)*2] = mapCell{char: mg.connectorV, spec: connSpec} + grid[(y+1)*2+1][(x+1)*2] = ctx.connectorCell(topRoom, bottomRoom, world.South, world.North) } } } @@ -226,6 +223,10 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) dimSpec := resolveDim(g, sess) + ctx := &mapRenderCtx{ + g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + } grid := make([][]mapCell, mapHeight) for i := range grid { @@ -264,11 +265,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m gr := cy + y*2 gc := cx + x*2 + 1 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - connSpec := color.NoColor() - if visited != nil && (!visited[rid] || !visited[rightID]) { - connSpec = dimSpec - } - grid[gr][gc] = mapCell{char: mg.connectorH, spec: connSpec} + grid[gr][gc] = ctx.connectorCell(rid, rightID, world.East, world.West) } } } @@ -278,11 +275,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m gr := cy + y*2 + 1 gc := cx + x*2 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - connSpec := color.NoColor() - if visited != nil && (!visited[rid] || !visited[bottomID]) { - connSpec = dimSpec - } - grid[gr][gc] = mapCell{char: mg.connectorV, spec: connSpec} + grid[gr][gc] = ctx.connectorCell(rid, bottomID, world.South, world.North) } } } @@ -316,15 +309,145 @@ func resolveDim(g *Game, sess *net.Session) color.ColorSpec { if sess != nil { return g.resolveColor(sess, "dim") } - return color.Parse("243 dim") + return color.Parse("F3 dim") +} + +func resolveMapBlocked(g *Game, sess *net.Session) color.ColorSpec { + if sess != nil { + return g.resolveColor(sess, "map_blocked") + } + return color.Parse("C4") +} + +// mapRenderCtx bundles the per-render state shared by node and connector drawing +// so the tiny and full maps build cells the same way. +type mapRenderCtx struct { + g *Game + sess *net.Session + bg *mapGraph + visited map[int]bool + currentRoom int + atSpec color.ColorSpec + dimSpec color.ColorSpec + blockedSpec color.ColorSpec + mg mapGlyphs +} + +// nodeSpec returns the effective color a room's node is drawn with, mirroring +// the logic used when placing room glyphs. +func (c *mapRenderCtx) nodeSpec(roomID int) color.ColorSpec { + if roomID == c.currentRoom { + return c.atSpec + } + if c.visited != nil && !c.visited[roomID] { + return c.dimSpec + } + _, spec := roomMapSymbol(c.g, c.sess, roomID, false) + return spec +} + +// connectorCell builds the link cell between two grid-adjacent rooms based on +// the per-direction traversability of the two exits joining them: +// - both directions open -> bidirectional bar (- / |) +// - exactly one open -> arrow pointing along the open direction +// - neither open (>=1 blocked) -> blocked 'X' +// +// Bars and arrows use the normal link coloring (dim if an endpoint is unvisited, +// otherwise the gradient average); only 'X' uses the blocked color. +func (c *mapRenderCtx) connectorCell(roomA, roomB int, dirAB, dirBA world.ExitDir) mapCell { + fwd := exitStateTo(c.g, c.sess, roomA, dirAB, roomB) // A -> B + bwd := exitStateTo(c.g, c.sess, roomB, dirBA, roomA) // B -> A + + var glyph rune + switch { + case fwd == exitOpen && bwd == exitOpen: + glyph = barGlyph(c.mg, dirAB) + case fwd == exitOpen: + glyph = arrowGlyph(c.mg, dirAB) + case bwd == exitOpen: + glyph = arrowGlyph(c.mg, dirBA) + default: + // The caller only draws a connector when at least one exit exists, so + // reaching here means every existing direction is blocked. + return mapCell{char: 'X', spec: c.blockedSpec} + } + + if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { + return mapCell{char: glyph, spec: c.dimSpec} + } + return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))} +} + +type exitState int + +const ( + exitAbsent exitState = iota + exitOpen + exitBlocked +) + +// exitStateTo reports whether the exit from `from` in `dir` leads to `neighbor` +// and, if so, whether it is currently traversable for this player. A missing +// session/player (e.g. in tests or background renders) treats conditional exits +// as open so rendering never depends on player evaluation. +func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighbor int) exitState { + room, ok := loadRoom(g, from) + if !ok { + return exitAbsent + } + exit, ok := room.Exits[dir] + if !ok || exit.Room != neighbor { + return exitAbsent + } + if exit.Condition == nil || sess == nil || sess.Player == nil { + return exitOpen + } + if g.checkCondition(sess, exit.Condition) { + return exitOpen + } + return exitBlocked +} + +// barGlyph returns the bidirectional connector glyph for a link's orientation. +func barGlyph(mg mapGlyphs, dir world.ExitDir) rune { + if dir == world.East || dir == world.West { + return mg.connectorH + } + return mg.connectorV +} + +// arrowGlyph returns the one-way arrow pointing along the direction of travel. +func arrowGlyph(mg mapGlyphs, dir world.ExitDir) rune { + switch dir { + case world.East: + return mg.rightArrow + case world.West: + return mg.leftArrow + case world.South: + return mg.downArrow + case world.North: + return mg.upArrow + } + return mg.connectorH +} + +// roomDefaultColorSpec resolves the room's own default map color (feature 3). +// Empty/unset returns NoColor. +func roomDefaultColorSpec(g *Game, roomID int) color.ColorSpec { + if room, ok := loadRoom(g, roomID); ok && room.Color != "" { + return color.Parse(room.Color) + } + return color.NoColor() } func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune, color.ColorSpec) { + roomSpec := roomDefaultColorSpec(g, roomID) if sess != nil && sess.Player != nil { if data, ok := sess.Player.MapSymbols[roomID]; ok { r, size := utf8.DecodeRuneInString(data.Char) if size > 0 && r != utf8.RuneError { - spec := color.NoColor() + // precedence: player symbol color > room default color > none + spec := roomSpec if data.Color != "" { spec = color.Parse(data.Color) } @@ -338,13 +461,13 @@ func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune } if sess.Player.OptionBool("unicode") { if unvisited { - return '□', color.NoColor() + return '□', roomSpec } - return '■', color.NoColor() + return '■', roomSpec } - return 'o', color.NoColor() + return 'o', roomSpec } - return '■', color.NoColor() + return '■', roomSpec } func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool { -- cgit v1.2.3