From feb80b7dde200d4121cd9f9c583a08f9869c5588 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 17:19:40 -0400 Subject: feat: incardinal directions (nw, ne, sw, se). probably janky in ways I haven't yet discovered. --- internal/game/cmd_close.go | 2 +- internal/game/cmd_dig.go | 76 +++++++--- internal/game/cmd_registry.go | 8 ++ internal/game/cmd_room.go | 37 +++++ internal/game/cmd_room_insert.go | 290 +++++++++++++++++++++++++++++++++++++++ internal/game/cmd_undig.go | 2 +- internal/game/cmd_walk.go | 36 ++++- internal/game/map_test.go | 80 +++++++++++ internal/game/render_help.go | 2 +- internal/game/render_map.go | 277 ++++++++++++++++++++++++++++++++++--- internal/game/render_prompt.go | 30 +++- 11 files changed, 795 insertions(+), 45 deletions(-) create mode 100644 internal/game/cmd_room_insert.go (limited to 'internal/game') diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go index 8dab0d9..217ae9a 100644 --- a/internal/game/cmd_close.go +++ b/internal/game/cmd_close.go @@ -25,7 +25,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { dir := g.World.ResolveExit(args[0]) if dir == "" { - sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.") return } diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index e7023b2..653f045 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -23,13 +23,13 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { return } if len(args) == 0 { - sess.WriteLine("Dig where? (north/south/east/west/up/down)") + sess.WriteLine("Dig where? (n/s/e/w/ne/nw/se/sw/u/d)") return } dir := g.World.ResolveExit(args[0]) if dir == "" { - sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.") return } @@ -82,6 +82,49 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { } sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName)) + + oldRoom := p.RoomID + p.ClearMoveState() + if g.Combat.Get(p.Name) != nil { + g.stopCombat(p.Name) + } + p.Action = nil + g.cancelRest(p.Name) + g.cancelEnterSeq(p.Name) + if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { + p.EnterSeqRoom = 0 + } + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + + p.RoomID = conflictID + p.HazardTimer = 0 + p.Stats.RecordRoomVisit(conflictID) + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(conflictID) + g.seedRoomMobs(conflictID) + g.seedRoomObjects(conflictID) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir)) + } + } + g.Hub.EnterRoom(sess, conflictID) + for _, other := range g.Hub.PlayersInRoom(conflictID) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name)) + } + } + } + + sess.WriteLine(fmt.Sprintf("You step through the new link to %s.", conflictName)) + g.doLook(sess) + g.runEnterSteps(sess, conflictID) + g.checkAggro(sess) return } @@ -220,22 +263,11 @@ func findNextRoomID(currentRoomPath string) (int, error) { } } -var gridDeltas = map[world.ExitDir][2]int{ - world.North: {0, -1}, - world.South: {0, 1}, - world.East: {1, 0}, - world.West: {-1, 0}, -} - -func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { - delta, ok := gridDeltas[dir] - if !ok { - return 0 - } +func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][2]int, roomAt map[[2]int]int) { roomIndex := g.World.RoomIndex() - coord := map[int][2]int{fromRoomID: {0, 0}} - roomAt := map[[2]int]int{{0, 0}: fromRoomID} + coord = map[int][2]int{fromRoomID: {0, 0}} + roomAt = map[[2]int]int{{0, 0}: fromRoomID} queue := []int{fromRoomID} for len(queue) > 0 { @@ -255,7 +287,7 @@ func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { if ed == world.Up || ed == world.Down { continue } - gd, ok := gridDeltas[ed] + gd, ok := world.DirectionDeltas[ed] if !ok { continue } @@ -272,6 +304,16 @@ func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { queue = append(queue, target) } } + return coord, roomAt +} + +func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int { + delta, ok := world.DirectionDeltas[dir] + if !ok { + return 0 + } + + _, roomAt := g.buildGridFrom(fromRoomID) targetCoord := [2]int{delta[0], delta[1]} if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID { diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 562fc71..bc77936 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -34,6 +34,14 @@ var commandRegistry = map[string]commandDef{ "e": {(*Game).executeMove, ClassActive}, "west": {(*Game).executeMove, ClassActive}, "w": {(*Game).executeMove, ClassActive}, + "northeast": {(*Game).executeMove, ClassActive}, + "ne": {(*Game).executeMove, ClassActive}, + "northwest": {(*Game).executeMove, ClassActive}, + "nw": {(*Game).executeMove, ClassActive}, + "southeast": {(*Game).executeMove, ClassActive}, + "se": {(*Game).executeMove, ClassActive}, + "southwest": {(*Game).executeMove, ClassActive}, + "sw": {(*Game).executeMove, ClassActive}, "up": {(*Game).executeMove, ClassActive}, "u": {(*Game).executeMove, ClassActive}, "down": {(*Game).executeMove, ClassActive}, diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go index 24763fa..971e298 100644 --- a/internal/game/cmd_room.go +++ b/internal/game/cmd_room.go @@ -7,6 +7,7 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" @@ -46,6 +47,10 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) { g.roomMob(sess, action, rest) case "addspawn", "remspawn": g.roomSpawn(sess, action, rest) + case "color": + g.roomSetColor(sess, rest) + case "insert": + g.roomInsert(sess, rest) default: sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action)) g.showRoomHelp(sess) @@ -66,6 +71,8 @@ func (g *Game) showRoomHelp(sess *net.Session) { sess.WriteLine(" room remmob — remove a mob spawn") sess.WriteLine(" room addspawn [qty] [respawn_ticks] — add an item spawn") sess.WriteLine(" room remspawn — remove an item spawn") + sess.WriteLine(" room color — set or clear room map color") + sess.WriteLine(" room insert [name] — insert a new room into the exit, pushing rooms beyond") } func (g *Game) roomSetName(sess *net.Session, args []string) { @@ -92,6 +99,36 @@ func (g *Game) roomSetDesc(sess *net.Session, args []string) { sess.WriteLine("Room description updated.") } +func (g *Game) roomSetColor(sess *net.Session, args []string) { + if len(args) == 0 { + sess.WriteLine("Usage: room color ") + sess.WriteLine("Format: <00-FF> [bg:<00-FF>] [bold] [dim] [underline]") + sess.WriteLine("Example: D0 bold") + return + } + value := strings.Join(args, " ") + if value == "off" { + value = "" + } + if value != "" { + spec := color.Parse(value) + if spec.Empty() { + sess.WriteLine(fmt.Sprintf("Invalid color string: %s", value)) + sess.WriteLine("Format: <00-FF> [bg:<00-FF>] [bold] [dim] [underline]") + sess.WriteLine("Example: D0 bold") + return + } + } + g.writeRoom(sess, func(room *world.Room) { + room.Color = value + }) + if value == "" { + sess.WriteLine("Room color cleared.") + } else { + sess.WriteLine(fmt.Sprintf("Room color set to: %s", value)) + } +} + func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) { path, ok := g.World.GetRoomPath(p.RoomID) if !ok { diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go new file mode 100644 index 0000000..4fbcd10 --- /dev/null +++ b/internal/game/cmd_room_insert.go @@ -0,0 +1,290 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +func (g *Game) roomInsert(sess *net.Session, args []string) { + p := sess.Player + if p == nil { + return + } + + if len(args) == 0 { + sess.WriteLine("Usage: room insert [name]") + return + } + + dir := g.World.ResolveExit(args[0]) + if dir == "" { + sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.") + return + } + + var name string + if len(args) > 1 { + name = strings.Join(args[1:], " ") + } else { + name = "New Room" + } + + curPath, ok := g.World.GetRoomPath(p.RoomID) + if !ok { + sess.WriteLine("Error: can't find current room file.") + return + } + + curRoom, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("Error loading current room.") + return + } + + exitDef, hasExit := curRoom.Exits[dir] + if !hasExit { + sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir)) + return + } + + targetID := exitDef.Room + targetRoom, err := g.World.LoadRoom(targetID) + if err != nil { + sess.WriteLine(fmt.Sprintf("Target room %d not found.", targetID)) + return + } + targetName := fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name) + + oppositeDir := world.OppositeExit[dir] + + if delta, isHorizontal := world.DirectionDeltas[dir]; isHorizontal { + coord, roomAt := g.buildGridFrom(p.RoomID) + + sSet := g.bfsComponent(targetID, p.RoomID) + + withoutEdge := g.bfsWithoutEdge(p.RoomID, dir, targetID) + for rid := range sSet { + if withoutEdge[rid] { + room, _ := g.World.LoadRoom(rid) + conflictName := fmt.Sprintf("#%d", rid) + if room != nil { + conflictName = fmt.Sprintf("#%d (%s)", rid, room.Name) + } + sess.WriteLine(fmt.Sprintf( + "Cannot insert: room %s would have an ambiguous grid position after insertion (reachable via an alternate path from here).", + conflictName)) + return + } + } + + for rid := range sSet { + if _, inGrid := coord[rid]; !inGrid { + continue + } + oldPos := coord[rid] + newPos := [2]int{oldPos[0] + delta[0], oldPos[1] + delta[1]} + if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] { + occRoom, _ := g.World.LoadRoom(occupier) + occName := fmt.Sprintf("#%d", occupier) + if occRoom != nil { + occName = fmt.Sprintf("#%d (%s)", occupier, occRoom.Name) + } + sess.WriteLine(fmt.Sprintf( + "Cannot insert: pushing %s would cause grid collision with %s.", + targetName, occName)) + return + } + } + } + + newID, err := findNextRoomID(curPath) + if err != nil { + sess.WriteLine("Error scanning room directory.") + return + } + + dirPath := filepath.Dir(curPath) + newPath := filepath.Join(dirPath, strconv.Itoa(newID)+".yaml") + + newRoom := &world.Room{ + Name: name, + Description: behavior.DescList{ + {Text: "A featureless room."}, + }, + Exits: map[world.ExitDir]world.ExitDef{ + dir: {Room: targetID}, + oppositeDir: {Room: p.RoomID}, + }, + } + + data, err := yaml.Marshal(newRoom) + if err != nil { + sess.WriteLine("Error creating room YAML.") + return + } + if err := os.MkdirAll(dirPath, 0755); err != nil { + sess.WriteLine("Error creating directory.") + return + } + if err := os.WriteFile(newPath, data, 0644); err != nil { + sess.WriteLine("Error writing room file.") + return + } + + g.World.AddRoomPath(newID, newPath) + + curRoom.Exits[dir] = world.ExitDef{ + Room: newID, + Condition: exitDef.Condition, + BlockedMessage: exitDef.BlockedMessage, + SetFlags: exitDef.SetFlags, + SetPlayerFlags: exitDef.SetPlayerFlags, + } + curData, err := yaml.Marshal(curRoom) + if err != nil { + sess.WriteLine("Error updating current room YAML.") + return + } + if err := os.WriteFile(curPath, curData, 0644); err != nil { + sess.WriteLine("Error writing current room file.") + return + } + + if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID { + targetRoom.Exits[oppositeDir] = world.ExitDef{ + Room: newID, + Condition: targetExit.Condition, + BlockedMessage: targetExit.BlockedMessage, + SetFlags: targetExit.SetFlags, + SetPlayerFlags: targetExit.SetPlayerFlags, + } + } + targetPath, tok2 := g.World.GetRoomPath(targetID) + if tok2 { + targetData, terr := yaml.Marshal(targetRoom) + if terr == nil { + os.WriteFile(targetPath, targetData, 0644) + } + } + + oldRoom := p.RoomID + p.ClearMoveState() + if g.Combat.Get(p.Name) != nil { + g.stopCombat(p.Name) + } + p.Action = nil + g.cancelRest(p.Name) + g.cancelEnterSeq(p.Name) + if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { + p.EnterSeqRoom = 0 + } + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + + p.RoomID = newID + p.HazardTimer = 0 + p.Stats.RecordRoomVisit(newID) + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(newID) + g.seedRoomMobs(newID) + g.seedRoomObjects(newID) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s vanishes into the tunnel.", p.Name)) + } + } + g.Hub.EnterRoom(sess, newID) + for _, other := range g.Hub.PlayersInRoom(newID) { + if other != sess { + other.WriteLine(fmt.Sprintf("%s appears.", p.Name)) + } + } + } + + g.World.RebuildRoomIndex(g.DataDir) + + sess.WriteLine(fmt.Sprintf("You insert a room to the %s and create Room #%d (%s).", dir, newID, name)) + g.doLook(sess) + g.runEnterSteps(sess, newID) + g.checkAggro(sess) +} + +func (g *Game) bfsComponent(startID, excludeID int) map[int]bool { + roomIndex := g.World.RoomIndex() + visited := map[int]bool{startID: true} + queue := []int{startID} + + for len(queue) > 0 { + rid := queue[0] + queue = queue[1:] + room, err := g.World.LoadRoom(rid) + if err != nil { + continue + } + for _, ed := range world.ExitOrder { + if ed == world.Up || ed == world.Down { + continue + } + exit, ok := room.Exits[ed] + if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { + continue + } + target := exit.Room + if target == excludeID { + continue + } + if visited[target] { + continue + } + visited[target] = true + queue = append(queue, target) + } + } + return visited +} + +func (g *Game) bfsWithoutEdge(fromID int, skipDir world.ExitDir, skipTo int) map[int]bool { + roomIndex := g.World.RoomIndex() + visited := map[int]bool{fromID: true} + queue := []int{fromID} + + for len(queue) > 0 { + rid := queue[0] + queue = queue[1:] + room, err := g.World.LoadRoom(rid) + if err != nil { + continue + } + for _, ed := range world.ExitOrder { + if ed == world.Up || ed == world.Down { + continue + } + exit, ok := room.Exits[ed] + if !ok || exit.Room <= 0 || !roomIndex[exit.Room] { + continue + } + if rid == fromID && ed == skipDir && exit.Room == skipTo { + continue + } + target := exit.Room + if visited[target] { + continue + } + visited[target] = true + queue = append(queue, target) + } + } + return visited +} diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index 2cfae70..1fc70c1 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -30,7 +30,7 @@ func (g *Game) executeUndig(sess *net.Session, args []string, rawInput string) { dir := g.World.ResolveExit(args[0]) if dir == "" { - sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.") + sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.") return } diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index d7d51e2..ec62eec 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -60,9 +60,25 @@ func parseWalkDirections(input string) ([]string, error) { var dir string switch ch { case 'n': - dir = string(world.North) + if i < len(input) && input[i] == 'e' { + i++ + dir = string(world.Northeast) + } else if i < len(input) && input[i] == 'w' { + i++ + dir = string(world.Northwest) + } else { + dir = string(world.North) + } case 's': - dir = string(world.South) + if i < len(input) && input[i] == 'e' { + i++ + dir = string(world.Southeast) + } else if i < len(input) && input[i] == 'w' { + i++ + dir = string(world.Southwest) + } else { + dir = string(world.South) + } case 'e': dir = string(world.East) case 'w': @@ -150,6 +166,14 @@ func directionToChar(dir string) string { return "e" case world.West: return "w" + case world.Northeast: + return "ne" + case world.Northwest: + return "nw" + case world.Southeast: + return "se" + case world.Southwest: + return "sw" case world.Up: return "u" case world.Down: @@ -158,7 +182,11 @@ func directionToChar(dir string) string { return "?" } -var bfsSearchDirs = []world.ExitDir{world.North, world.South, world.East, world.West, world.Up, world.Down} +var walkSearchDirs = []world.ExitDir{ + world.North, world.South, world.East, world.West, + world.Northeast, world.Northwest, world.Southeast, world.Southwest, + world.Up, world.Down, +} func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { if fromRoom == toRoom { @@ -183,7 +211,7 @@ func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { continue } - for _, dir := range bfsSearchDirs { + for _, dir := range walkSearchDirs { exitDef, exists := room.Exits[dir] if !exists { continue diff --git a/internal/game/map_test.go b/internal/game/map_test.go index 0d529cf..ebb2197 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -31,12 +31,15 @@ func writeTempRoom(t *testing.T, dir string, id int, body string) { func TestMapConnectorGlyphs(t *testing.T) { const condEast = "name: One\nexits:\n east:\n room: 2\n condition:\n flag: gate_open\n" const condSouth = "name: Three\nexits:\n south:\n room: 2\n condition:\n flag: gate_open\n" + const condNE = "name: One\nexits:\n northeast:\n room: 2\n condition:\n flag: gate_open\n" + const condSE = "name: One\nexits:\n southeast:\n room: 2\n condition:\n flag: gate_open\n" cases := []struct { name string room1 string room2 string room3 string + room4 string flagOpen bool wantPresent string wantAbsent []string @@ -91,6 +94,59 @@ func TestMapConnectorGlyphs(t *testing.T) { wantPresent: "^", wantAbsent: []string{"X", "v", "<", ">"}, }, + { + name: "bidirectional diagonal NE-SW", + room1: "name: One\nexits:\n northeast: 2\n", + room2: "name: Two\nexits:\n southwest: 1\n", + wantPresent: "/", + wantAbsent: []string{"X", "\\"}, + }, + { + name: "bidirectional diagonal NW-SE", + room1: "name: One\nexits:\n northwest: 2\n", + room2: "name: Two\nexits:\n southeast: 1\n", + wantPresent: "\\", + wantAbsent: []string{"X", "/"}, + }, + { + name: "one-way NE arrow", + room1: "name: One\nexits:\n northeast: 2\n", + room2: "name: Two\n", + wantPresent: "/", + wantAbsent: []string{"X", "\\"}, + }, + { + name: "diagonal NE blocked conditional -> X", + room1: condNE, + room2: "name: Two\n", + flagOpen: false, + wantPresent: "X", + wantAbsent: []string{"\\", "/"}, + }, + { + name: "diagonal SE blocked conditional -> X", + room1: condSE, + room2: "name: Two\n", + flagOpen: false, + wantPresent: "X", + wantAbsent: []string{"\\", "/"}, + }, + { + name: "diagonal conditional unblocked -> bar", + room1: condNE, + room2: "name: Two\nexits:\n southwest: 1\n", + flagOpen: true, + wantPresent: "/", + wantAbsent: []string{"X", "\\"}, + }, + { + name: "criss-crossed diagonal paths show X", + room1: "name: One\nexits:\n east: 2\n south: 3\n southeast: 4\n", + room2: "name: Two\nexits:\n southwest: 3\n", + room3: "name: Three\n", + room4: "name: Four\n", + wantPresent: "X", + }, } for _, tc := range cases { @@ -101,6 +157,9 @@ func TestMapConnectorGlyphs(t *testing.T) { if tc.room3 != "" { writeTempRoom(t, dir, 3, tc.room3) } + if tc.room4 != "" { + writeTempRoom(t, dir, 4, tc.room4) + } g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()} if tc.flagOpen { @@ -121,6 +180,27 @@ func TestMapConnectorGlyphs(t *testing.T) { } } +// TestMapDiagonalOneWayCrossing verifies that when a one-way diagonal arrow +// (↗) crosses a bidirectional diagonal bar (/) at the same grid cell, the +// cell renders as X rather than silently overwriting the arrow. This covers +// the unicode-mode criss-cross path where isDiagonalGlyph must recognize +// both bar glyphs and arrow glyphs. +func TestMapDiagonalOneWayCrossing(t *testing.T) { + dir := t.TempDir() + writeTempRoom(t, dir, 1, "name: One\nexits:\n north: 3\n northeast: 2\n") + writeTempRoom(t, dir, 2, "name: Two\n") + writeTempRoom(t, dir, 3, "name: Three\nexits:\n southeast: 4\n") + writeTempRoom(t, dir, 4, "name: Four\nexits:\n northwest: 3\n") + + g := &Game{Deps: Deps{World: world.New(dir)}} + sess := &net.Session{Player: &player.Player{Options: map[string]any{"unicode": true}}} + + out := strings.Join(buildTinyMap(g, sess, 1, mapGlyphsForPlayer(true)), "\n") + if !strings.Contains(out, "X") { + t.Errorf("expected X for crossed one-way NE arrow and SE bar:\n%s", out) + } +} + func TestBuildTinyMap(t *testing.T) { g := &Game{ Deps: Deps{World: world.New("../../data")}, diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 798eadf..7b7efa2 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -56,7 +56,7 @@ var commandList = []cmdEntry{ {"mine", "Active", "Mine rocks (Mining)"}, {"mix", "Active", "Mix potions (Pharmacy)"}, {"mods / modlist", "Instant", "List available science modules"}, - {"north / south / east / west / up / down", "Active", "Move in a direction"}, + {"north / south / east / west / ne / nw / se / sw / up / down", "Active", "Move in a direction"}, {"option / options", "Instant", "View or change settings"}, {"prompt", "Instant", "Set custom command prompt"}, {"pull / push", "Active", "Interact with objects"}, diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 7416d06..6b80688 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -17,6 +17,9 @@ type mapGlyphs struct { connectorH, connectorV rune upArrow, downArrow rune leftArrow, rightArrow rune + upRight, upLeft rune + downRight, downLeft rune + connectorNE, connectorNW rune } func mapGlyphsForPlayer(unicode bool) mapGlyphs { @@ -25,12 +28,16 @@ func mapGlyphsForPlayer(unicode bool) 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: '\\', } } @@ -45,15 +52,7 @@ type mapGraph struct { dist map[int]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{ @@ -84,15 +83,15 @@ func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph { continue } - for _, d := range bfsDirs { - targetID, ok := exitTarget(room, d.dir) + for dir, delta := range world.DirectionDeltas { + targetID, ok := exitTarget(room, dir) if !ok { continue } if _, seen := mg.roomToPos[targetID]; seen { continue } - nx, ny := n.x+d.dx, n.y+d.dy + nx, ny := n.x+delta[0], n.y+delta[1] mg.posToRoom[[2]int{nx, ny}] = targetID mg.roomToPos[targetID] = [2]int{nx, ny} mg.dist[targetID] = mg.dist[n.roomID] + 1 @@ -137,6 +136,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + diagPairs: make(map[[2]int][2]int), } grid := make([][]mapCell, 5) @@ -199,21 +199,145 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string } } + // NE connectors: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3] + for y := 0; y <= 1; y++ { + for x := -1; x <= 0; x++ { + aPos, bPos := [2]int{x, y}, [2]int{x + 1, y - 1} + aRoom, aOK := bg.posToRoom[aPos] + bRoom, bOK := bg.posToRoom[bPos] + if !aOK || !bOK { + continue + } + if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northeast, world.Southwest); ok { + gr, gc := 2*y+1, 2*x+3 + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{aRoom, bRoom} + } + } + } + } + // NW connectors: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1] + for y := 0; y <= 1; y++ { + for x := 0; x <= 1; x++ { + aPos, bPos := [2]int{x, y}, [2]int{x - 1, y - 1} + aRoom, aOK := bg.posToRoom[aPos] + bRoom, bOK := bg.posToRoom[bPos] + if !aOK || !bOK { + continue + } + if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northwest, world.Southeast); ok { + gr, gc := 2*y+1, 2*x+1 + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{aRoom, bRoom} + } + } + } + } + // SE connectors: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3] + for y := -1; y <= 0; y++ { + for x := -1; x <= 0; x++ { + aPos, bPos := [2]int{x, y}, [2]int{x + 1, y + 1} + aRoom, aOK := bg.posToRoom[aPos] + bRoom, bOK := bg.posToRoom[bPos] + if !aOK || !bOK { + continue + } + if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southeast, world.Northwest); ok { + gr, gc := 2*y+3, 2*x+3 + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{aRoom, bRoom} + } + } + } + } + // SW connectors: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1] + for y := -1; y <= 0; y++ { + for x := 0; x <= 1; x++ { + aPos, bPos := [2]int{x, y}, [2]int{x - 1, y + 1} + aRoom, aOK := bg.posToRoom[aPos] + bRoom, bOK := bg.posToRoom[bPos] + if !aOK || !bOK { + continue + } + if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southwest, world.Northeast); ok { + gr, gc := 2*y+3, 2*x+1 + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{aRoom, bRoom} + } + } + } + } + cur, _ := loadRoom(g, roomID) if cur != nil { - if target, ok := exitTarget(cur, world.Up); ok { + if upTarget, hasUp := exitTarget(cur, world.Up); hasUp { spec := color.NoColor() - if exitStateTo(g, sess, roomID, world.Up, target) == exitBlocked { + if exitStateTo(g, sess, roomID, world.Up, upTarget) == exitBlocked { spec = ctx.blockedSpec } - grid[1][3] = mapCell{char: mg.upArrow, spec: spec} + switch { + case grid[1][3].char == ' ': + grid[1][3] = mapCell{char: mg.upArrow, spec: spec} + case grid[1][1].char == ' ': + grid[1][1] = mapCell{char: mg.upArrow, spec: spec} + case grid[1][2].char == ' ': + grid[1][2] = mapCell{char: mg.upArrow, spec: spec} + } } - if target, ok := exitTarget(cur, world.Down); ok { + if downTarget, hasDown := exitTarget(cur, world.Down); hasDown { spec := color.NoColor() - if exitStateTo(g, sess, roomID, world.Down, target) == exitBlocked { + if exitStateTo(g, sess, roomID, world.Down, downTarget) == exitBlocked { spec = ctx.blockedSpec } - grid[3][1] = mapCell{char: mg.downArrow, spec: spec} + if grid[3][1].char == ' ' { + grid[3][1] = mapCell{char: mg.downArrow, spec: spec} + } else if grid[3][3].char == ' ' { + grid[3][3] = mapCell{char: mg.downArrow, spec: spec} + } } } @@ -238,6 +362,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + diagPairs: make(map[[2]int][2]int), } grid := make([][]mapCell, mapHeight) @@ -291,6 +416,98 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m } } } + + if neID, exists := bg.posToRoom[[2]int{x + 1, y - 1}]; exists { + gr := cy + y*2 - 1 + gc := cx + x*2 + 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + if cell, ok := ctx.connectorCell(rid, neID, world.Northeast, world.Southwest); ok { + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(neID)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{rid, neID} + } + } + } + } + + if nwID, exists := bg.posToRoom[[2]int{x - 1, y - 1}]; exists { + gr := cy + y*2 - 1 + gc := cx + x*2 - 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + if cell, ok := ctx.connectorCell(rid, nwID, world.Northwest, world.Southeast); ok { + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(nwID)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{rid, nwID} + } + } + } + } + + if seID, exists := bg.posToRoom[[2]int{x + 1, y + 1}]; exists { + gr := cy + y*2 + 1 + gc := cx + x*2 + 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + if cell, ok := ctx.connectorCell(rid, seID, world.Southeast, world.Northwest); ok { + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(seID)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{rid, seID} + } + } + } + } + + if swID, exists := bg.posToRoom[[2]int{x - 1, y + 1}]; exists { + gr := cy + y*2 + 1 + gc := cx + x*2 - 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + if cell, ok := ctx.connectorCell(rid, swID, world.Southwest, world.Northeast); ok { + key := [2]int{gr, gc} + if isDiagonalGlyph(grid[gr][gc].char) { + if grid[gr][gc].char != cell.char { + prev := ctx.diagPairs[key] + spec := color.Average( + color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])), + color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(swID)), + ) + grid[gr][gc] = mapCell{char: 'X', spec: spec} + } + } else { + grid[gr][gc] = cell + ctx.diagPairs[key] = [2]int{rid, swID} + } + } + } + } } return renderMapCells(grid, colorMode, 0, mapHeight, 0) @@ -343,6 +560,7 @@ type mapRenderCtx struct { dimSpec color.ColorSpec blockedSpec color.ColorSpec mg mapGlyphs + diagPairs map[[2]int][2]int } // nodeSpec returns the effective color a room's node is drawn with, mirroring @@ -441,12 +659,23 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb 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 { - if dir == world.East || dir == world.West { + 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.connectorV + return mg.connectorH } // arrowGlyph returns the one-way arrow pointing along the direction of travel. @@ -460,6 +689,14 @@ func arrowGlyph(mg mapGlyphs, dir world.ExitDir) rune { 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 } diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go index b992c8c..68f1a9a 100644 --- a/internal/game/render_prompt.go +++ b/internal/game/render_prompt.go @@ -59,7 +59,7 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string { if !ok { continue } - letter := strings.ToUpper(string(dir)[:1]) + letter := exitPromptLetter(dir) allExits += letter if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { continue @@ -114,3 +114,31 @@ func xpToLevel(p *player.Player, sk player.SkillName) int { nextLevelXP := player.XPForLevel(currentLevel + 1) return nextLevelXP - currentXP } + +// Cardinal exits get uppercase single letters; intercardinal exits get lowercase +// two-letter strings. This visual distinction helps players scan the prompt at a glance. +func exitPromptLetter(dir world.ExitDir) string { + switch dir { + case world.North: + return "N" + case world.South: + return "S" + case world.East: + return "E" + case world.West: + return "W" + case world.Northeast: + return "ne" + case world.Northwest: + return "nw" + case world.Southeast: + return "se" + case world.Southwest: + return "sw" + case world.Up: + return "U" + case world.Down: + return "D" + } + return "?" +} -- cgit v1.2.3