feat: arrow-key room navigation, neighbor visibility, map UX improvements
- Exploration uses Up/Down + Enter instead of number keys - Adjacent rooms shown with cursor selection in HUD - Neighboring rooms visible on fog of war map - Room numbers displayed on tile map with type-colored markers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
catacombs.exe~
Normal file
BIN
catacombs.exe~
Normal file
Binary file not shown.
@@ -23,5 +23,13 @@ func GetRoomVisibility(floor *Floor, roomIdx int) Visibility {
|
||||
if floor.Rooms[roomIdx].Visited {
|
||||
return Visited
|
||||
}
|
||||
// Neighbors of current room are dimly visible (so player can see where to go)
|
||||
if floor.CurrentRoom >= 0 && floor.CurrentRoom < len(floor.Rooms) {
|
||||
for _, n := range floor.Rooms[floor.CurrentRoom].Neighbors {
|
||||
if n == roomIdx {
|
||||
return Visited
|
||||
}
|
||||
}
|
||||
}
|
||||
return Hidden
|
||||
}
|
||||
|
||||
@@ -112,8 +112,18 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
||||
cx := room.X + room.W/2
|
||||
cy := room.Y + room.H/2
|
||||
vis := GetRoomVisibility(floor, i)
|
||||
if !showFog || vis != Hidden {
|
||||
sym, col := roomMarker(room, i == currentRoom)
|
||||
// Show current room and its neighbors (so player knows where to go)
|
||||
showRoom := !showFog || vis != Hidden
|
||||
if !showRoom && currentRoom >= 0 && currentRoom < len(floor.Rooms) {
|
||||
for _, n := range floor.Rooms[currentRoom].Neighbors {
|
||||
if n == i {
|
||||
showRoom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if showRoom {
|
||||
sym, col := roomMarker(room, i, i == currentRoom)
|
||||
markers[[2]int{cy, cx}] = marker{sym, col}
|
||||
}
|
||||
}
|
||||
@@ -202,24 +212,26 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func roomMarker(room *Room, isCurrent bool) (string, string) {
|
||||
func roomMarker(room *Room, roomIdx int, isCurrent bool) (string, string) {
|
||||
// Show room index number so player knows which key to press
|
||||
num := fmt.Sprintf("%d", roomIdx)
|
||||
if room.Cleared {
|
||||
return ".", ansiFgGray
|
||||
return num, ansiFgGray
|
||||
}
|
||||
switch room.Type {
|
||||
case RoomCombat:
|
||||
return "D", ansiFgRed
|
||||
return num, ansiFgRed
|
||||
case RoomTreasure:
|
||||
return "$", ansiFgYellow
|
||||
return num, ansiFgYellow
|
||||
case RoomShop:
|
||||
return "S", ansiFgCyan
|
||||
return num, ansiFgCyan
|
||||
case RoomEvent:
|
||||
return "?", ansiFgMagenta
|
||||
return num, ansiFgMagenta
|
||||
case RoomBoss:
|
||||
return "B", ansiFgBrRed
|
||||
return num, ansiFgBrRed
|
||||
case RoomEmpty:
|
||||
return ".", ansiFgGray
|
||||
return num, ansiFgGray
|
||||
default:
|
||||
return " ", ansiReset
|
||||
return num, ansiReset
|
||||
}
|
||||
}
|
||||
|
||||
18
main.go
18
main.go
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/tolelom/catacombs/game"
|
||||
"github.com/tolelom/catacombs/server"
|
||||
@@ -22,16 +20,8 @@ func main() {
|
||||
|
||||
lobby := game.NewLobby()
|
||||
|
||||
go func() {
|
||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("Catacombs server running on :2222")
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
<-c
|
||||
log.Println("Shutting down...")
|
||||
log.Println("Catacombs server starting on :2222")
|
||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
|
||||
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
||||
return true // accept all keys
|
||||
}),
|
||||
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
||||
return true // accept any password (game server, not secure shell)
|
||||
}),
|
||||
wish.WithMiddleware(
|
||||
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pty, _, _ := s.Pty()
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/tolelom/catacombs/game"
|
||||
)
|
||||
|
||||
func renderGame(state game.GameState, width, height int, targetCursor int) string {
|
||||
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int) string {
|
||||
mapView := renderMap(state.Floor)
|
||||
hudView := renderHUD(state, targetCursor)
|
||||
hudView := renderHUD(state, targetCursor, moveCursor)
|
||||
logView := renderCombatLog(state.CombatLog)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
@@ -32,7 +32,7 @@ func renderMap(floor *dungeon.Floor) string {
|
||||
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
||||
}
|
||||
|
||||
func renderHUD(state game.GameState, targetCursor int) string {
|
||||
func renderHUD(state game.GameState, targetCursor int, moveCursor int) string {
|
||||
var sb strings.Builder
|
||||
border := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
@@ -112,7 +112,32 @@ func renderHUD(state game.GameState, targetCursor int) string {
|
||||
}
|
||||
}
|
||||
} else if state.Phase == game.PhaseExploring {
|
||||
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
|
||||
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
|
||||
current := state.Floor.Rooms[state.Floor.CurrentRoom]
|
||||
if len(current.Neighbors) > 0 {
|
||||
sb.WriteString("\n")
|
||||
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
|
||||
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||
for i, n := range current.Neighbors {
|
||||
if n >= 0 && n < len(state.Floor.Rooms) {
|
||||
r := state.Floor.Rooms[n]
|
||||
status := r.Type.String()
|
||||
if r.Cleared {
|
||||
status = "Cleared"
|
||||
}
|
||||
marker := " "
|
||||
style := normalStyle
|
||||
if i == moveCursor {
|
||||
marker = "> "
|
||||
style = selectedStyle
|
||||
}
|
||||
sb.WriteString(style.Render(fmt.Sprintf("%sRoom %d: %s", marker, n, status)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit")
|
||||
}
|
||||
|
||||
return border.Render(sb.String())
|
||||
|
||||
35
ui/model.go
35
ui/model.go
@@ -46,6 +46,7 @@ type Model struct {
|
||||
classState classSelectState
|
||||
inputBuffer string
|
||||
targetCursor int
|
||||
moveCursor int // selected neighbor index during exploration
|
||||
}
|
||||
|
||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
||||
@@ -115,7 +116,7 @@ func (m Model) View() string {
|
||||
case screenClassSelect:
|
||||
return renderClassSelect(m.classState, m.width, m.height)
|
||||
case screenGame:
|
||||
return renderGame(m.gameState, m.width, m.height, m.targetCursor)
|
||||
return renderGame(m.gameState, m.width, m.height, m.targetCursor, m.moveCursor)
|
||||
case screenShop:
|
||||
return renderShop(m.gameState, m.width, m.height)
|
||||
case screenResult:
|
||||
@@ -312,16 +313,27 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch m.gameState.Phase {
|
||||
case game.PhaseExploring:
|
||||
if key.String() >= "0" && key.String() <= "9" {
|
||||
idx := int(key.String()[0] - '0')
|
||||
if m.session != nil {
|
||||
m.session.EnterRoom(idx)
|
||||
neighbors := m.getNeighbors()
|
||||
if isUp(key) {
|
||||
if m.moveCursor > 0 {
|
||||
m.moveCursor--
|
||||
}
|
||||
} else if isDown(key) {
|
||||
if m.moveCursor < len(neighbors)-1 {
|
||||
m.moveCursor++
|
||||
}
|
||||
} else if isEnter(key) {
|
||||
if m.session != nil && len(neighbors) > 0 {
|
||||
roomIdx := neighbors[m.moveCursor]
|
||||
m.session.EnterRoom(roomIdx)
|
||||
m.gameState = m.session.GetState()
|
||||
// If combat started, begin polling
|
||||
m.moveCursor = 0
|
||||
if m.gameState.Phase == game.PhaseCombat {
|
||||
return m, m.pollState()
|
||||
}
|
||||
}
|
||||
} else if isQuit(key) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case game.PhaseCombat:
|
||||
isPlayerDead := false
|
||||
@@ -361,6 +373,17 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) getNeighbors() []int {
|
||||
if m.gameState.Floor == nil {
|
||||
return nil
|
||||
}
|
||||
cur := m.gameState.Floor.CurrentRoom
|
||||
if cur < 0 || cur >= len(m.gameState.Floor.Rooms) {
|
||||
return nil
|
||||
}
|
||||
return m.gameState.Floor.Rooms[cur].Neighbors
|
||||
}
|
||||
|
||||
func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
|
||||
Reference in New Issue
Block a user