package game import ( "strings" "unicode/utf8" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) type mapGlyphs struct { topLeft, topRight rune bottomLeft, bottomRight rune side rune topFill rune connectorH, connectorV rune upArrow, downArrow rune leftArrow, rightArrow rune upRight, upLeft rune downRight, downLeft rune connectorNE, connectorNW rune } func mapGlyphsForPlayer(unicode bool) mapGlyphs { if unicode { return mapGlyphs{ topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', side: '║', topFill: '═', connectorH: '-', connectorV: '│', upArrow: '↑', downArrow: '↓', leftArrow: '←', rightArrow: '→', upRight: '↗', upLeft: '↖', downRight: '↘', downLeft: '↙', connectorNE: '/', connectorNW: '\\', } } return mapGlyphs{ topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':', side: ':', topFill: '.', connectorH: '-', connectorV: '|', upArrow: '^', downArrow: 'v', leftArrow: '<', rightArrow: '>', upRight: '/', upLeft: '\\', downRight: '\\', downLeft: '/', connectorNE: '/', connectorNW: '\\', } } type mapCell struct { char rune spec color.ColorSpec } type mapGraph struct { posToRoom map[[3]int]int dist map[int]int } // The map BFS always seeds the player's room at the grid origin, so the // player's z-plane is constant. const playerZ = 0 func buildGraph(g *Game, startRoomID int) *mapGraph { rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) { return loadRoom(g, id) }, nil, nil) return &mapGraph{posToRoom: rg.RoomAt, dist: rg.Dist} } func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, border rune) []string { lines := make([]string, 0, endRow-startRow) for row := startRow; row < endRow; row++ { var sb strings.Builder if border != 0 { sb.WriteRune(border) } for _, cell := range grid[row] { if cell.char == ' ' { sb.WriteRune(' ') } else if !cell.spec.Empty() { sb.WriteString(color.Render(colorMode, cell.spec, string(cell.char))) } else { sb.WriteRune(cell.char) } } if border != 0 { sb.WriteRune(border) } lines = append(lines, sb.String()) } return lines } func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string { visited := roomsVisited(sess) bg := buildGraph(g, roomID) 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), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } grid := make([][]mapCell, 5) for i := range grid { grid[i] = make([]mapCell, 5) for j := range grid[i] { grid[i][j] = mapCell{char: ' '} } } pz := playerZ for y := -1; y <= 1; y++ { for x := -1; x <= 1; x++ { pos := [3]int{x, y, pz} rid, ok := bg.posToRoom[pos] if !ok { continue } gr := (y + 1) * 2 gc := (x + 1) * 2 if rid == roomID { grid[gr][gc] = mapCell{char: '@', spec: atSpec} } else { unvisited := visited != nil && !visited[rid] ch, spec := roomMapSymbol(g, sess, rid, unvisited) if unvisited { spec = dimSpec } grid[gr][gc] = mapCell{char: ch, spec: spec} } } } for y := -1; y <= 1; y++ { for x := -1; x <= 0; x++ { leftPos := [3]int{x, y, pz} rightPos := [3]int{x + 1, y, pz} leftRoom, leftOK := bg.posToRoom[leftPos] rightRoom, rightOK := bg.posToRoom[rightPos] if !leftOK || !rightOK { continue } if cell, ok := ctx.connectorCell(leftRoom, rightRoom, world.East, world.West); ok { grid[(y+1)*2][(x+1)*2+1] = cell } } } for y := -1; y <= 0; y++ { for x := -1; x <= 1; x++ { topPos := [3]int{x, y, pz} bottomPos := [3]int{x, y + 1, pz} topRoom, topOK := bg.posToRoom[topPos] bottomRoom, bottomOK := bg.posToRoom[bottomPos] if !topOK || !bottomOK { continue } if cell, ok := ctx.connectorCell(topRoom, bottomRoom, world.South, world.North); ok { grid[(y+1)*2+1][(x+1)*2] = cell } } } // Diagonal connectors. Each maps room (x,y) to a diagonal neighbor; the // grid cell between them is 2*y/2*x offset by ±1 in each axis. for y := 0; y <= 1; y++ { for x := -1; x <= 0; x++ { // NE: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3] aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] bRoom, bOK := bg.posToRoom[[3]int{x + 1, y - 1, pz}] if aOK && bOK { ctx.placeDiagonal(grid, 2*y+1, 2*x+3, aRoom, bRoom, world.Northeast, world.Southwest) } } } for y := 0; y <= 1; y++ { for x := 0; x <= 1; x++ { // NW: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1] aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] bRoom, bOK := bg.posToRoom[[3]int{x - 1, y - 1, pz}] if aOK && bOK { ctx.placeDiagonal(grid, 2*y+1, 2*x+1, aRoom, bRoom, world.Northwest, world.Southeast) } } } for y := -1; y <= 0; y++ { for x := -1; x <= 0; x++ { // SE: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3] aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] bRoom, bOK := bg.posToRoom[[3]int{x + 1, y + 1, pz}] if aOK && bOK { ctx.placeDiagonal(grid, 2*y+3, 2*x+3, aRoom, bRoom, world.Southeast, world.Northwest) } } } for y := -1; y <= 0; y++ { for x := 0; x <= 1; x++ { // SW: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1] aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}] bRoom, bOK := bg.posToRoom[[3]int{x - 1, y + 1, pz}] if aOK && bOK { ctx.placeDiagonal(grid, 2*y+3, 2*x+1, aRoom, bRoom, world.Southwest, world.Northeast) } } } cur, _ := loadRoom(g, roomID) if cur != nil { _, hasUp := exitTarget(cur, world.Up) _, hasDown := exitTarget(cur, world.Down) upCorners := [][2]int{ {1, 3}, // NE {1, 1}, // NW {3, 3}, // SE {3, 1}, // SW } downCorners := [][2]int{ {3, 1}, // SW {3, 3}, // SE {1, 3}, // NE {1, 1}, // NW } placeArrow := func(corners [][2]int, glyph rune, blockedSpec color.ColorSpec) { for _, c := range corners { if grid[c[0]][c[1]].char == ' ' { grid[c[0]][c[1]] = mapCell{char: glyph, spec: blockedSpec} return } } } if hasUp { spec := color.NoColor() if upTarget, _ := exitTarget(cur, world.Up); upTarget != 0 && exitStateTo(g, sess, roomID, world.Up, upTarget) == exitBlocked { spec = ctx.blockedSpec } placeArrow(upCorners, mg.upArrow, spec) } if hasDown { spec := color.NoColor() if downTarget, _ := exitTarget(cur, world.Down); downTarget != 0 && exitStateTo(g, sess, roomID, world.Down, downTarget) == exitBlocked { spec = ctx.blockedSpec } placeArrow(downCorners, mg.downArrow, spec) } } topFill := strings.Repeat(string(mg.topFill), 5) lines := make([]string, 7) lines[0] = string(mg.topLeft) + topFill + string(mg.topRight) inner := renderMapCells(grid, colorMode, 0, 5, mg.side) copy(lines[1:], inner) botFill := strings.Repeat(string(mg.topFill), 5) lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight) return lines } func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string { visited := roomsVisited(sess) bg := buildGraph(g, roomID) 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), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } grid := make([][]mapCell, mapHeight) for i := range grid { grid[i] = make([]mapCell, mapWidth) for j := range grid[i] { grid[i][j] = mapCell{char: ' '} } } cx := mapWidth / 2 cy := mapHeight / 2 pz := playerZ for pos, rid := range bg.posToRoom { if pos[2] != pz { continue } gr := cy + pos[1]*2 gc := cx + pos[0]*2 if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { continue } if rid == roomID { grid[gr][gc] = mapCell{char: '@', spec: atSpec} } else { unvisited := visited != nil && !visited[rid] ch, spec := roomMapSymbol(g, sess, rid, unvisited) if unvisited { spec = dimSpec } grid[gr][gc] = mapCell{char: ch, spec: spec} } } for pos, rid := range bg.posToRoom { if pos[2] != pz { continue } x, y := pos[0], pos[1] if rightID, exists := bg.posToRoom[[3]int{x + 1, y, pz}]; exists { gr := cy + y*2 gc := cx + x*2 + 1 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { if cell, ok := ctx.connectorCell(rid, rightID, world.East, world.West); ok { grid[gr][gc] = cell } } } if bottomID, exists := bg.posToRoom[[3]int{x, y + 1, pz}]; exists { gr := cy + y*2 + 1 gc := cx + x*2 if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { if cell, ok := ctx.connectorCell(rid, bottomID, world.South, world.North); ok { grid[gr][gc] = cell } } } if neID, exists := bg.posToRoom[[3]int{x + 1, y - 1, pz}]; exists { ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2+1, rid, neID, world.Northeast, world.Southwest) } if nwID, exists := bg.posToRoom[[3]int{x - 1, y - 1, pz}]; exists { ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2-1, rid, nwID, world.Northwest, world.Southeast) } if seID, exists := bg.posToRoom[[3]int{x + 1, y + 1, pz}]; exists { ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2+1, rid, seID, world.Southeast, world.Northwest) } if swID, exists := bg.posToRoom[[3]int{x - 1, y + 1, pz}]; exists { ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2-1, rid, swID, world.Southwest, world.Northeast) } } cur, _ := loadRoom(g, roomID) if cur != nil { _, hasUp := exitTarget(cur, world.Up) _, hasDown := exitTarget(cur, world.Down) upCorners := [][2]int{ {cy - 1, cx + 1}, // NE {cy - 1, cx - 1}, // NW {cy + 1, cx + 1}, // SE {cy + 1, cx - 1}, // SW } downCorners := [][2]int{ {cy + 1, cx - 1}, // SW {cy + 1, cx + 1}, // SE {cy - 1, cx + 1}, // NE {cy - 1, cx - 1}, // NW } placeArrow := func(corners [][2]int, glyph rune, blockedSpec color.ColorSpec) { for _, c := range corners { r, col := c[0], c[1] if r >= 0 && r < mapHeight && col >= 0 && col < mapWidth && grid[r][col].char == ' ' { grid[r][col] = mapCell{char: glyph, spec: blockedSpec} return } } } if hasUp { spec := color.NoColor() if upTarget, _ := exitTarget(cur, world.Up); upTarget != 0 && exitStateTo(g, sess, roomID, world.Up, upTarget) == exitBlocked { spec = ctx.blockedSpec } placeArrow(upCorners, mg.upArrow, spec) } if hasDown { spec := color.NoColor() if downTarget, _ := exitTarget(cur, world.Down); downTarget != 0 && exitStateTo(g, sess, roomID, world.Down, downTarget) == exitBlocked { spec = ctx.blockedSpec } placeArrow(downCorners, mg.downArrow, spec) } } return renderMapCells(grid, colorMode, 0, mapHeight, 0) } func roomsVisited(sess *net.Session) map[int]bool { if sess != nil && sess.Player != nil { return sess.Player.Stats.RoomsVisited } return nil } func colorModeFor(sess *net.Session) string { if sess != nil && sess.Player != nil { return sess.Player.OptionString("color") } return "none" } func resolveMapAt(g *Game, sess *net.Session) color.ColorSpec { if sess != nil { return g.resolveColor(sess, "map_at") } return color.Parse("0F") } func resolveDim(g *Game, sess *net.Session) color.ColorSpec { if sess != nil { return g.resolveColor(sess, "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") } func resolveMapCourse(g *Game, sess *net.Session) color.ColorSpec { if sess != nil { return g.resolveColor(sess, "map_course") } return color.Parse("1B") } // 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 courseSpec color.ColorSpec mg mapGlyphs diagPairs map[[2]int][2]int } // 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. ok is false // when there is no link at all, so the caller draws nothing: // - both directions open -> bidirectional bar (- / |) // - outward direction open -> arrow pointing outward // - outward direction blocked -> blocked 'X' // - only inward direction open -> arrow pointing inward // - none traversable -> blocked 'X' // - neither exit exists -> ok == false (no cell) // // "Outward" is the exit from the room nearer the player (smaller BFS distance) // toward the farther one — i.e. the link as reached along the shortest path. // Since each BFS hop is a unit grid step, grid-adjacent rooms always differ in // distance parity, so there is never a tie to break. // // 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, bool) { fwd := exitStateTo(c.g, c.sess, roomA, dirAB, roomB) // A -> B bwd := exitStateTo(c.g, c.sess, roomB, dirBA, roomA) // B -> A switch { case fwd == exitAbsent && bwd == exitAbsent: return mapCell{}, false case fwd == exitOpen && bwd == exitOpen: return c.coloredCell(roomA, roomB, barGlyph(c.mg, dirAB)) } // Orient the link outward, from near room to far room. outDir, inDir, out, in := dirAB, dirBA, fwd, bwd if c.bg.dist[roomB] < c.bg.dist[roomA] { outDir, inDir, out, in = dirBA, dirAB, bwd, fwd } switch { case out == exitOpen: if c.isCourseLink(roomA, roomB) { return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) case out == exitAbsent && in == exitOpen: if c.isCourseLink(roomA, roomB) { return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) default: return mapCell{char: 'X', spec: c.blockedSpec}, true } } // isCourseLink reports whether roomA and roomB are consecutive obstacles in // the same agility course (one's NextRoom equals the other's room id). func (c *mapRenderCtx) isCourseLink(roomA, roomB int) bool { if c.g == nil || c.g.CourseStore == nil { return false } if info := c.g.CourseStore.GetObstacle(roomA); info != nil && info.NextRoom == roomB { return true } if info := c.g.CourseStore.GetObstacle(roomB); info != nil && info.NextRoom == roomA { return true } return false } // courseColoredCell draws a one-way course arrow using the map_course color // (dimmed if either endpoint is unvisited). func (c *mapRenderCtx) courseColoredCell(roomA, roomB int, glyph rune) (mapCell, bool) { if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { return mapCell{char: glyph, spec: c.dimSpec}, true } return mapCell{char: glyph, spec: c.courseSpec}, true } // coloredCell applies the shared link coloring logic for bar/arrow glyphs // (dim if either endpoint is unvisited, otherwise the gradient average). func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool) { if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { return mapCell{char: glyph, spec: c.dimSpec}, true } return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))}, true } // placeDiagonal draws the diagonal link between two grid-adjacent rooms at // grid[gr][gc], handling the criss-cross case: when a diagonal glyph is already // present and the new link runs the opposite way, the cell becomes a blocked // 'X' colored from both crossing links' endpoints. Out-of-bounds targets and // rooms with no link between them are no-ops. func (c *mapRenderCtx) placeDiagonal(grid [][]mapCell, gr, gc, roomA, roomB int, dirAB, dirBA world.ExitDir) { if gr < 0 || gr >= len(grid) || gc < 0 || gc >= len(grid[gr]) { return } cell, ok := c.connectorCell(roomA, roomB, dirAB, dirBA) if !ok { return } key := [2]int{gr, gc} if isDiagonalGlyph(grid[gr][gc].char) { if grid[gr][gc].char != cell.char { prev := c.diagPairs[key] spec := color.Average( color.Average(c.nodeSpec(prev[0]), c.nodeSpec(prev[1])), color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB)), ) grid[gr][gc] = mapCell{char: 'X', spec: spec} } return } grid[gr][gc] = cell c.diagPairs[key] = [2]int{roomA, 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 } // Hidden exits are non-traversable to players but are followed by the map // layout BFS, so they always render as traversable (open) connectors. if exit.Hidden { return exitOpen } if exit.Condition == nil || sess == nil || sess.Player == nil { return exitOpen } if sess.Player.GodMode || g.checkCondition(sess, exit.Condition) { return exitOpen } return exitBlocked } func isDiagonalGlyph(ch rune) bool { return ch == '\\' || ch == '/' || ch == '↗' || ch == '↖' || ch == '↘' || ch == '↙' } // barGlyph returns the bidirectional connector glyph for a link's orientation. func barGlyph(mg mapGlyphs, dir world.ExitDir) rune { switch dir { case world.East, world.West: return mg.connectorH case world.North, world.South: return mg.connectorV case world.Northeast, world.Southwest: return mg.connectorNE case world.Northwest, world.Southeast: return mg.connectorNW } return mg.connectorH } // 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 case world.Northeast: return mg.upRight case world.Northwest: return mg.upLeft case world.Southeast: return mg.downRight case world.Southwest: return mg.downLeft } 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 { // precedence: player symbol color > room default color > none spec := roomSpec if data.Color != "" { spec = color.Parse(data.Color) } // non-ASCII custom symbols fall back to 'o' // when unicode mode is off, but preserve the color. if !sess.Player.OptionBool("unicode") && r > 127 { return 'o', spec } return r, spec } } if sess.Player.OptionBool("unicode") { if unvisited { return '□', roomSpec } return '■', roomSpec } return 'o', roomSpec } return '■', roomSpec } func exitTarget(room *world.Room, dir world.ExitDir) (int, bool) { if room == nil { return 0, false } exit, ok := room.Exits[dir] if !ok { return 0, false } return exit.Room, true } func loadRoom(g *Game, roomID int) (*world.Room, bool) { if roomID == 0 { return nil, false } room, err := g.World.LoadRoom(roomID) if err != nil { return nil, false } return room, true } func stripBlankRows(lines []string) []string { top := -1 for i, line := range lines { if strings.TrimSpace(line) != "" { top = i break } } if top < 0 { return nil } bottom := top for i := len(lines) - 1; i > bottom; i-- { if strings.TrimSpace(lines[i]) != "" { bottom = i break } } return lines[top : bottom+1] } func leftTrimCommon(lines []string) []string { min := -1 for _, line := range lines { if strings.TrimSpace(line) == "" { continue } n := 0 for _, r := range line { if r == ' ' { n++ } else { break } } if min < 0 || n < min { min = n } } if min <= 0 { return lines } result := make([]string, len(lines)) for i, line := range lines { if len(line) <= min { result[i] = "" } else { result[i] = line[min:] } } return result }