aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_map.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 15:40:48 -0400
committerhistoria <[not public]>2026-06-25 15:40:48 -0400
commitabd612c15799f604e671e83dc7c410ed2b44185f (patch)
tree0597ca92350de73aac2a7cf26b2f2d6595dac0cc /internal/game/render_map.go
parent2725e2927a1595c7b100d942d1f14146252adeb7 (diff)
downloadthehouseoficarus-abd612c15799f604e671e83dc7c410ed2b44185f.tar.gz
slop refactor
Diffstat (limited to 'internal/game/render_map.go')
-rw-r--r--internal/game/render_map.go428
1 files changed, 428 insertions, 0 deletions
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
new file mode 100644
index 0000000..2bac749
--- /dev/null
+++ b/internal/game/render_map.go
@@ -0,0 +1,428 @@
+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
+}