aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_map.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-27 16:50:41 -0400
committerhistoria <[not public]>2026-06-27 16:50:41 -0400
commit988b006a5bb9edb6465653566e7bdf8175347b8c (patch)
tree3a1fceff2ee80c11b5dafcc49c13ababe021e880 /internal/game/render_map.go
parent1cab9ca20e24742f770a676f84e4ed9f1636f297 (diff)
downloadthehouseoficarus-988b006a5bb9edb6465653566e7bdf8175347b8c.tar.gz
feat: one-way map links, blocked paths on map, map grid startup validation, user colors for maps
Diffstat (limited to 'internal/game/render_map.go')
-rw-r--r--internal/game/render_map.go179
1 files changed, 151 insertions, 28 deletions
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 {