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 {
|
if floor.Rooms[roomIdx].Visited {
|
||||||
return 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
|
return Hidden
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,8 +112,18 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
|||||||
cx := room.X + room.W/2
|
cx := room.X + room.W/2
|
||||||
cy := room.Y + room.H/2
|
cy := room.Y + room.H/2
|
||||||
vis := GetRoomVisibility(floor, i)
|
vis := GetRoomVisibility(floor, i)
|
||||||
if !showFog || vis != Hidden {
|
// Show current room and its neighbors (so player knows where to go)
|
||||||
sym, col := roomMarker(room, i == currentRoom)
|
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}
|
markers[[2]int{cy, cx}] = marker{sym, col}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,24 +212,26 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
|
|||||||
return string(buf)
|
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 {
|
if room.Cleared {
|
||||||
return ".", ansiFgGray
|
return num, ansiFgGray
|
||||||
}
|
}
|
||||||
switch room.Type {
|
switch room.Type {
|
||||||
case RoomCombat:
|
case RoomCombat:
|
||||||
return "D", ansiFgRed
|
return num, ansiFgRed
|
||||||
case RoomTreasure:
|
case RoomTreasure:
|
||||||
return "$", ansiFgYellow
|
return num, ansiFgYellow
|
||||||
case RoomShop:
|
case RoomShop:
|
||||||
return "S", ansiFgCyan
|
return num, ansiFgCyan
|
||||||
case RoomEvent:
|
case RoomEvent:
|
||||||
return "?", ansiFgMagenta
|
return num, ansiFgMagenta
|
||||||
case RoomBoss:
|
case RoomBoss:
|
||||||
return "B", ansiFgBrRed
|
return num, ansiFgBrRed
|
||||||
case RoomEmpty:
|
case RoomEmpty:
|
||||||
return ".", ansiFgGray
|
return num, ansiFgGray
|
||||||
default:
|
default:
|
||||||
return " ", ansiReset
|
return num, ansiReset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
main.go
18
main.go
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
"github.com/tolelom/catacombs/server"
|
"github.com/tolelom/catacombs/server"
|
||||||
@@ -22,16 +20,8 @@ func main() {
|
|||||||
|
|
||||||
lobby := game.NewLobby()
|
lobby := game.NewLobby()
|
||||||
|
|
||||||
go func() {
|
log.Println("Catacombs server starting on :2222")
|
||||||
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
|
||||||
log.Fatal(err)
|
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...")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
|
||||||
return true // accept all keys
|
return true // accept all keys
|
||||||
}),
|
}),
|
||||||
|
wish.WithPasswordAuth(func(_ ssh.Context, _ string) bool {
|
||||||
|
return true // accept any password (game server, not secure shell)
|
||||||
|
}),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
pty, _, _ := s.Pty()
|
pty, _, _ := s.Pty()
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/tolelom/catacombs/game"
|
"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)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state, targetCursor)
|
hudView := renderHUD(state, targetCursor, moveCursor)
|
||||||
logView := renderCombatLog(state.CombatLog)
|
logView := renderCombatLog(state.CombatLog)
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
@@ -32,7 +32,7 @@ func renderMap(floor *dungeon.Floor) string {
|
|||||||
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
|
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
|
var sb strings.Builder
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
@@ -112,7 +112,32 @@ func renderHUD(state game.GameState, targetCursor int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if state.Phase == game.PhaseExploring {
|
} 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())
|
return border.Render(sb.String())
|
||||||
|
|||||||
35
ui/model.go
35
ui/model.go
@@ -46,6 +46,7 @@ type Model struct {
|
|||||||
classState classSelectState
|
classState classSelectState
|
||||||
inputBuffer string
|
inputBuffer string
|
||||||
targetCursor int
|
targetCursor int
|
||||||
|
moveCursor int // selected neighbor index during exploration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
|
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:
|
case screenClassSelect:
|
||||||
return renderClassSelect(m.classState, m.width, m.height)
|
return renderClassSelect(m.classState, m.width, m.height)
|
||||||
case screenGame:
|
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:
|
case screenShop:
|
||||||
return renderShop(m.gameState, m.width, m.height)
|
return renderShop(m.gameState, m.width, m.height)
|
||||||
case screenResult:
|
case screenResult:
|
||||||
@@ -312,16 +313,27 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
switch m.gameState.Phase {
|
switch m.gameState.Phase {
|
||||||
case game.PhaseExploring:
|
case game.PhaseExploring:
|
||||||
if key.String() >= "0" && key.String() <= "9" {
|
neighbors := m.getNeighbors()
|
||||||
idx := int(key.String()[0] - '0')
|
if isUp(key) {
|
||||||
if m.session != nil {
|
if m.moveCursor > 0 {
|
||||||
m.session.EnterRoom(idx)
|
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()
|
m.gameState = m.session.GetState()
|
||||||
// If combat started, begin polling
|
m.moveCursor = 0
|
||||||
if m.gameState.Phase == game.PhaseCombat {
|
if m.gameState.Phase == game.PhaseCombat {
|
||||||
return m, m.pollState()
|
return m, m.pollState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isQuit(key) {
|
||||||
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
case game.PhaseCombat:
|
case game.PhaseCombat:
|
||||||
isPlayerDead := false
|
isPlayerDead := false
|
||||||
@@ -361,6 +373,17 @@ func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
|
|||||||
Reference in New Issue
Block a user