fix: sync multiplayer exploration via vote system and fix combat bugs
- Add room vote system for multiplayer exploration (prevents players from independently moving the party to different rooms) - Fix Healer skill targeting: use ally cursor (Shift+Tab) instead of monster cursor, preventing wrong-target or out-of-bounds access - Prevent duplicate action submissions in the same combat turn - Drain stale actions from channel between turns - Block dead players from submitting actions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,11 @@ func (s *GameSession) EnterRoom(roomIdx int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.enterRoomLocked(roomIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enterRoomLocked performs room entry logic. Caller must hold s.mu.
|
||||||
|
func (s *GameSession) enterRoomLocked(roomIdx int) {
|
||||||
s.state.Floor.CurrentRoom = roomIdx
|
s.state.Floor.CurrentRoom = roomIdx
|
||||||
dungeon.UpdateVisibility(s.state.Floor)
|
dungeon.UpdateVisibility(s.state.Floor)
|
||||||
room := s.state.Floor.Rooms[roomIdx]
|
room := s.state.Floor.Rooms[roomIdx]
|
||||||
|
|||||||
106
game/session.go
106
game/session.go
@@ -56,6 +56,7 @@ type GameState struct {
|
|||||||
BossKilled bool
|
BossKilled bool
|
||||||
FleeSucceeded bool
|
FleeSucceeded bool
|
||||||
LastEventName string // name of the most recent random event (for codex)
|
LastEventName string // name of the most recent random event (for codex)
|
||||||
|
MoveVotes map[string]int // fingerprint -> voted room index (exploration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) addLog(msg string) {
|
func (s *GameSession) addLog(msg string) {
|
||||||
@@ -83,6 +84,7 @@ type GameSession struct {
|
|||||||
combatSignal chan struct{}
|
combatSignal chan struct{}
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
lastActivity map[string]time.Time // fingerprint -> last activity time
|
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||||
|
moveVotes map[string]int // fingerprint -> voted room index
|
||||||
HardMode bool
|
HardMode bool
|
||||||
ActiveMutation *Mutation
|
ActiveMutation *Mutation
|
||||||
DailyMode bool
|
DailyMode bool
|
||||||
@@ -302,6 +304,15 @@ func (s *GameSession) GetState() GameState {
|
|||||||
submittedCopy[k] = v
|
submittedCopy[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy move votes
|
||||||
|
var moveVotesCopy map[string]int
|
||||||
|
if s.state.MoveVotes != nil {
|
||||||
|
moveVotesCopy = make(map[string]int, len(s.state.MoveVotes))
|
||||||
|
for k, v := range s.state.MoveVotes {
|
||||||
|
moveVotesCopy[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Copy pending logs
|
// Copy pending logs
|
||||||
pendingCopy := make([]string, len(s.state.PendingLogs))
|
pendingCopy := make([]string, len(s.state.PendingLogs))
|
||||||
copy(pendingCopy, s.state.PendingLogs)
|
copy(pendingCopy, s.state.PendingLogs)
|
||||||
@@ -326,12 +337,28 @@ func (s *GameSession) GetState() GameState {
|
|||||||
BossKilled: s.state.BossKilled,
|
BossKilled: s.state.BossKilled,
|
||||||
FleeSucceeded: s.state.FleeSucceeded,
|
FleeSucceeded: s.state.FleeSucceeded,
|
||||||
LastEventName: s.state.LastEventName,
|
LastEventName: s.state.LastEventName,
|
||||||
|
MoveVotes: moveVotesCopy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
|
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.lastActivity[playerID] = time.Now()
|
s.lastActivity[playerID] = time.Now()
|
||||||
|
|
||||||
|
// Block dead/out players from submitting
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if p.Fingerprint == playerID && p.IsOut() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate submissions in the same turn
|
||||||
|
if _, already := s.state.SubmittedActions[playerID]; already {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
desc := ""
|
desc := ""
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case ActionAttack:
|
case ActionAttack:
|
||||||
@@ -442,3 +469,82 @@ func (s *GameSession) LeaveShop() {
|
|||||||
s.state.Phase = PhaseExploring
|
s.state.Phase = PhaseExploring
|
||||||
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitMoveVote records a player's room choice during exploration.
|
||||||
|
// When all alive players have voted, the majority choice wins and the party moves.
|
||||||
|
// Returns true if the vote triggered a move (all votes collected).
|
||||||
|
func (s *GameSession) SubmitMoveVote(fingerprint string, roomIdx int) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.state.Phase != PhaseExploring {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastActivity[fingerprint] = time.Now()
|
||||||
|
|
||||||
|
if s.moveVotes == nil {
|
||||||
|
s.moveVotes = make(map[string]int)
|
||||||
|
}
|
||||||
|
s.moveVotes[fingerprint] = roomIdx
|
||||||
|
|
||||||
|
// Copy votes to state for UI display
|
||||||
|
s.state.MoveVotes = make(map[string]int, len(s.moveVotes))
|
||||||
|
for k, v := range s.moveVotes {
|
||||||
|
s.state.MoveVotes[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all alive players have voted
|
||||||
|
aliveCount := 0
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
aliveCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voteCount := 0
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
if _, ok := s.moveVotes[p.Fingerprint]; ok {
|
||||||
|
voteCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if voteCount < aliveCount {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// All voted — resolve by majority
|
||||||
|
tally := make(map[int]int)
|
||||||
|
for _, p := range s.state.Players {
|
||||||
|
if !p.IsOut() {
|
||||||
|
if room, ok := s.moveVotes[p.Fingerprint]; ok {
|
||||||
|
tally[room]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bestRoom := -1
|
||||||
|
bestCount := 0
|
||||||
|
for room, count := range tally {
|
||||||
|
if count > bestCount || (count == bestCount && room < bestRoom) {
|
||||||
|
bestRoom = room
|
||||||
|
bestCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear votes
|
||||||
|
s.moveVotes = nil
|
||||||
|
s.state.MoveVotes = nil
|
||||||
|
|
||||||
|
// Execute the move (inline EnterRoom logic since we already hold the lock)
|
||||||
|
s.enterRoomLocked(bestRoom)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearMoveVotes resets any pending move votes (e.g. when phase changes).
|
||||||
|
func (s *GameSession) ClearMoveVotes() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.moveVotes = nil
|
||||||
|
s.state.MoveVotes = nil
|
||||||
|
}
|
||||||
|
|||||||
10
game/turn.go
10
game/turn.go
@@ -25,6 +25,16 @@ func (s *GameSession) RunTurn() {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Drain stale actions from previous turn
|
||||||
|
draining:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.actionCh:
|
||||||
|
default:
|
||||||
|
break draining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect actions with timeout
|
// Collect actions with timeout
|
||||||
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
|
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
|
||||||
timer := time.NewTimer(turnTimeout)
|
timer := time.NewTimer(turnTimeout)
|
||||||
|
|||||||
116
ui/game_view.go
116
ui/game_view.go
@@ -17,6 +17,7 @@ import (
|
|||||||
type GameScreen struct {
|
type GameScreen struct {
|
||||||
gameState game.GameState
|
gameState game.GameState
|
||||||
targetCursor int
|
targetCursor int
|
||||||
|
allyCursor int // for Healer skill targeting allies
|
||||||
moveCursor int
|
moveCursor int
|
||||||
chatting bool
|
chatting bool
|
||||||
chatInput string
|
chatInput string
|
||||||
@@ -289,18 +290,27 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
neighbors := s.getNeighbors()
|
neighbors := s.getNeighbors()
|
||||||
|
// Block input if this player already voted in multiplayer
|
||||||
|
alreadyVoted := false
|
||||||
|
if !s.gameState.SoloMode && s.gameState.MoveVotes != nil {
|
||||||
|
_, alreadyVoted = s.gameState.MoveVotes[ctx.Fingerprint]
|
||||||
|
}
|
||||||
if isUp(key) {
|
if isUp(key) {
|
||||||
if s.moveCursor > 0 {
|
if !alreadyVoted && s.moveCursor > 0 {
|
||||||
s.moveCursor--
|
s.moveCursor--
|
||||||
}
|
}
|
||||||
} else if isDown(key) {
|
} else if isDown(key) {
|
||||||
if s.moveCursor < len(neighbors)-1 {
|
if !alreadyVoted && s.moveCursor < len(neighbors)-1 {
|
||||||
s.moveCursor++
|
s.moveCursor++
|
||||||
}
|
}
|
||||||
} else if isEnter(key) {
|
} else if isEnter(key) {
|
||||||
if ctx.Session != nil && len(neighbors) > 0 {
|
if ctx.Session != nil && len(neighbors) > 0 && !alreadyVoted {
|
||||||
roomIdx := neighbors[s.moveCursor]
|
roomIdx := neighbors[s.moveCursor]
|
||||||
|
if s.gameState.SoloMode {
|
||||||
ctx.Session.EnterRoom(roomIdx)
|
ctx.Session.EnterRoom(roomIdx)
|
||||||
|
} else {
|
||||||
|
ctx.Session.SubmitMoveVote(ctx.Fingerprint, roomIdx)
|
||||||
|
}
|
||||||
s.gameState = ctx.Session.GetState()
|
s.gameState = ctx.Session.GetState()
|
||||||
s.moveCursor = 0
|
s.moveCursor = 0
|
||||||
if s.gameState.Phase == game.PhaseCombat {
|
if s.gameState.Phase == game.PhaseCombat {
|
||||||
@@ -324,17 +334,37 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
|||||||
return s, s.pollState()
|
return s, s.pollState()
|
||||||
}
|
}
|
||||||
if isKey(key, "tab") || key.Type == tea.KeyTab {
|
if isKey(key, "tab") || key.Type == tea.KeyTab {
|
||||||
|
if key.Type == tea.KeyShiftTab {
|
||||||
|
// Shift+Tab: cycle ally target (for Healer)
|
||||||
|
if len(s.gameState.Players) > 0 {
|
||||||
|
s.allyCursor = (s.allyCursor + 1) % len(s.gameState.Players)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab: cycle enemy target
|
||||||
if len(s.gameState.Monsters) > 0 {
|
if len(s.gameState.Monsters) > 0 {
|
||||||
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
|
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return s, s.pollState()
|
return s, s.pollState()
|
||||||
}
|
}
|
||||||
if ctx.Session != nil {
|
if ctx.Session != nil {
|
||||||
|
// Determine current player's class for skill targeting
|
||||||
|
myClass := entity.ClassWarrior
|
||||||
|
for _, p := range s.gameState.Players {
|
||||||
|
if p.Fingerprint == ctx.Fingerprint {
|
||||||
|
myClass = p.Class
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "1":
|
case "1":
|
||||||
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
|
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
|
||||||
case "2":
|
case "2":
|
||||||
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: s.targetCursor})
|
skillTarget := s.targetCursor
|
||||||
|
if myClass == entity.ClassHealer {
|
||||||
|
skillTarget = s.allyCursor
|
||||||
|
}
|
||||||
|
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: skillTarget})
|
||||||
case "3":
|
case "3":
|
||||||
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem})
|
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem})
|
||||||
case "4":
|
case "4":
|
||||||
@@ -350,12 +380,12 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameScreen) View(ctx *Context) string {
|
func (s *GameScreen) View(ctx *Context) string {
|
||||||
return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
|
return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.allyCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
|
func renderGame(state game.GameState, width, height int, targetCursor int, allyCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
|
||||||
mapView := renderMap(state.Floor)
|
mapView := renderMap(state.Floor)
|
||||||
hudView := renderHUD(state, targetCursor, moveCursor, fingerprint)
|
hudView := renderHUD(state, targetCursor, allyCursor, moveCursor, fingerprint)
|
||||||
logView := renderCombatLog(state.CombatLog)
|
logView := renderCombatLog(state.CombatLog)
|
||||||
|
|
||||||
if chatting {
|
if chatting {
|
||||||
@@ -391,7 +421,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, moveCursor int, fingerprint string) string {
|
func renderHUD(state game.GameState, targetCursor int, allyCursor int, moveCursor int, fingerprint string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
@@ -418,7 +448,15 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
|
|||||||
|
|
||||||
if state.Phase == game.PhaseCombat {
|
if state.Phase == game.PhaseCombat {
|
||||||
// Two-panel layout: PARTY | ENEMIES
|
// Two-panel layout: PARTY | ENEMIES
|
||||||
partyContent := renderPartyPanel(state.Players, state.SubmittedActions)
|
// Determine if current player is Healer for ally targeting display
|
||||||
|
isHealer := false
|
||||||
|
for _, p := range state.Players {
|
||||||
|
if p.Fingerprint == fingerprint && p.Class == entity.ClassHealer {
|
||||||
|
isHealer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partyContent := renderPartyPanel(state.Players, state.SubmittedActions, isHealer, allyCursor)
|
||||||
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
|
||||||
|
|
||||||
partyPanel := lipgloss.NewStyle().
|
partyPanel := lipgloss.NewStyle().
|
||||||
@@ -440,7 +478,11 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Action bar
|
// Action bar
|
||||||
|
if isHealer {
|
||||||
|
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]적 [Shift+Tab]아군 [/]채팅"))
|
||||||
|
} else {
|
||||||
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]대상 [/]채팅"))
|
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]대상 [/]채팅"))
|
||||||
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
@@ -474,10 +516,44 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if state.Phase == game.PhaseExploring {
|
} else if state.Phase == game.PhaseExploring {
|
||||||
|
// Count votes per room for display
|
||||||
|
votesPerRoom := make(map[int]int)
|
||||||
|
if state.MoveVotes != nil {
|
||||||
|
for _, room := range state.MoveVotes {
|
||||||
|
votesPerRoom[room]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
myVoted := false
|
||||||
|
if !state.SoloMode && state.MoveVotes != nil {
|
||||||
|
_, myVoted = state.MoveVotes[fingerprint]
|
||||||
|
}
|
||||||
|
|
||||||
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
|
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
|
||||||
current := state.Floor.Rooms[state.Floor.CurrentRoom]
|
current := state.Floor.Rooms[state.Floor.CurrentRoom]
|
||||||
if len(current.Neighbors) > 0 {
|
if len(current.Neighbors) > 0 {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
// Show vote status in multiplayer
|
||||||
|
if !state.SoloMode {
|
||||||
|
aliveCount := 0
|
||||||
|
votedCount := 0
|
||||||
|
for _, p := range state.Players {
|
||||||
|
if !p.IsDead() {
|
||||||
|
aliveCount++
|
||||||
|
if state.MoveVotes != nil {
|
||||||
|
if _, ok := state.MoveVotes[p.Fingerprint]; ok {
|
||||||
|
votedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
||||||
|
if myVoted {
|
||||||
|
sb.WriteString(voteStyle.Render(fmt.Sprintf("투표 완료! 대기 중... (%d/%d)", votedCount, aliveCount)))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(voteStyle.Render(fmt.Sprintf("이동할 방을 선택하세요 (%d/%d 투표)", votedCount, aliveCount)))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
|
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
|
||||||
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||||
for i, n := range current.Neighbors {
|
for i, n := range current.Neighbors {
|
||||||
@@ -493,7 +569,13 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
|
|||||||
marker = "> "
|
marker = "> "
|
||||||
style = selectedStyle
|
style = selectedStyle
|
||||||
}
|
}
|
||||||
sb.WriteString(style.Render(fmt.Sprintf("%s방 %d: %s", marker, n, status)))
|
voteInfo := ""
|
||||||
|
if !state.SoloMode {
|
||||||
|
if count, ok := votesPerRoom[n]; ok {
|
||||||
|
voteInfo = fmt.Sprintf(" [%d표]", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(style.Render(fmt.Sprintf("%s방 %d: %s%s", marker, n, status, voteInfo)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,8 +605,12 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !state.SoloMode && myVoted {
|
||||||
|
sb.WriteString("[Q] 종료 — 다른 파티원의 투표를 기다리는 중...")
|
||||||
|
} else {
|
||||||
sb.WriteString("[Up/Down] 선택 [Enter] 이동 [Q] 종료")
|
sb.WriteString("[Up/Down] 선택 [Enter] 이동 [Q] 종료")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if state.Phase == game.PhaseCombat {
|
if state.Phase == game.PhaseCombat {
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -599,12 +685,16 @@ func renderHPBar(current, max, width int) string {
|
|||||||
emptyStyle.Render(strings.Repeat("░", empty))
|
emptyStyle.Render(strings.Repeat("░", empty))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string) string {
|
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string, showAllyCursor bool, allyCursor int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(styleHeader.Render(" 아군") + "\n\n")
|
sb.WriteString(styleHeader.Render(" 아군") + "\n\n")
|
||||||
|
|
||||||
for _, p := range players {
|
for i, p := range players {
|
||||||
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name))
|
marker := " ♦"
|
||||||
|
if showAllyCursor && i == allyCursor {
|
||||||
|
marker = " >♦"
|
||||||
|
}
|
||||||
|
nameStr := stylePlayer.Render(fmt.Sprintf("%s %s", marker, p.Name))
|
||||||
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
|
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
|
||||||
status := ""
|
status := ""
|
||||||
if p.IsDead() {
|
if p.IsDead() {
|
||||||
|
|||||||
Reference in New Issue
Block a user