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 } func mapGlyphsForPlayer(unicode bool) mapGlyphs { if unicode { return mapGlyphs{ topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', side: '║', topFill: '═', connectorH: '-', connectorV: '│', upArrow: '↑', downArrow: '↓', } } return mapGlyphs{ topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':', side: ':', topFill: '.', connectorH: '-', connectorV: '|', upArrow: '^', downArrow: 'v', } } type mapCell struct { char rune spec color.ColorSpec } type mapGraph struct { posToRoom map[[2]int]int roomToPos map[int][2]int } var bfsDirs = []struct { dir world.ExitDir dx, dy int }{ {world.North, 0, -1}, {world.South, 0, 1}, {world.East, 1, 0}, {world.West, -1, 0}, } func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph { mg := &mapGraph{ posToRoom: make(map[[2]int]int), roomToPos: make(map[int][2]int), } type node struct { roomID int x, y int } queue := []node{{startRoomID, 0, 0}} mg.posToRoom[[2]int{0, 0}] = startRoomID mg.roomToPos[startRoomID] = [2]int{0, 0} for len(queue) > 0 { n := queue[0] queue = queue[1:] room, ok := loadRoom(g, n.roomID) if !ok { continue } if visited != nil && !visited[n.roomID] { continue } for _, d := range bfsDirs { targetID, ok := exitTarget(room, d.dir) if !ok { continue } if _, seen := mg.roomToPos[targetID]; seen { continue } nx, ny := n.x+d.dx, n.y+d.dy mg.posToRoom[[2]int{nx, ny}] = targetID mg.roomToPos[targetID] = [2]int{nx, ny} queue = append(queue, node{targetID, nx, ny}) } } return mg } 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, visited) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) dimSpec := resolveDim(g, sess) grid := make([][]mapCell, 5) for i := range grid { grid[i] = make([]mapCell, 5) for j := range grid[i] { grid[i][j] = mapCell{char: ' '} } } for y := -1; y <= 1; y++ { for x := -1; x <= 1; x++ { pos := [2]int{x, y} 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 := [2]int{x, y} rightPos := [2]int{x + 1, y} leftRoom, leftOK := bg.posToRoom[leftPos] rightRoom, rightOK := bg.posToRoom[rightPos] if !leftOK || !rightOK { 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} } } } for y := -1; y <= 0; y++ { for x := -1; x <= 1; x++ { topPos := [2]int{x, y} bottomPos := [2]int{x, y + 1} topRoom, topOK := bg.posToRoom[topPos] bottomRoom, bottomOK := bg.posToRoom[bottomPos] if !topOK || !bottomOK { 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} } } } cur, _ := loadRoom(g, roomID) if cur != nil { if _, ok := exitTarget(cur, world.Up); ok { grid[1][3] = mapCell{char: mg.upArrow, spec: color.NoColor()} } if _, ok := exitTarget(cur, world.Down); ok { grid[3][1] = mapCell{char: mg.downArrow, spec: color.NoColor()} } } 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, visited) colorMode := colorModeFor(sess) atSpec := resolveMapAt(g, sess) dimSpec := resolveDim(g, sess) 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 for pos, rid := range bg.posToRoom { 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 { x, y := pos[0], pos[1] if rightID, ok := bg.posToRoom[[2]int{x + 1, y}]; ok { if exitsConnect(g, rid, rightID, world.East, world.West) { 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} } } } if bottomID, ok := bg.posToRoom[[2]int{x, y + 1}]; ok { if exitsConnect(g, rid, bottomID, world.South, world.North) { 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} } } } } 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.NoColor() } func resolveDim(g *Game, sess *net.Session) color.ColorSpec { if sess != nil { return g.resolveColor(sess, "dim") } return color.Parse("243 dim") } func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune, color.ColorSpec) { 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() if data.Color != "" { spec = color.Parse(data.Color) } // ponytail: 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 '□', color.NoColor() } return '■', color.NoColor() } return 'o', color.NoColor() } return '■', color.NoColor() } func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool { r1, ok := loadRoom(g, room1) if !ok { return false } if id, ok := exitTarget(r1, dir12); ok && id == room2 { return true } r2, ok := loadRoom(g, room2) if !ok { return false } id, ok := exitTarget(r2, dir21) return ok && id == room1 } 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 { var out []string for _, line := range lines { if strings.TrimSpace(line) != "" { out = append(out, line) } } return out } 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 }