Combat foundation complete
This commit is contained in:
@@ -36,7 +36,7 @@ def combat_view(session_id: str):
|
||||
# Check if combat is still active
|
||||
if not result.get('in_combat'):
|
||||
# Combat ended - redirect to game play
|
||||
return redirect(url_for('game.play', session_id=session_id))
|
||||
return redirect(url_for('game.play_session', session_id=session_id))
|
||||
|
||||
encounter = result.get('encounter') or {}
|
||||
combat_log = result.get('combat_log', [])
|
||||
@@ -171,9 +171,11 @@ def combat_action(session_id: str):
|
||||
|
||||
# Add any effect entries
|
||||
for effect in result.get('effects_applied', []):
|
||||
# API may use "name" or "effect" key for the effect name
|
||||
effect_name = effect.get('name') or effect.get('effect') or 'Unknown'
|
||||
log_entries.append({
|
||||
'actor': '',
|
||||
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
|
||||
'message': effect.get('message', f'Effect applied: {effect_name}'),
|
||||
'type': 'system'
|
||||
})
|
||||
|
||||
@@ -417,15 +419,25 @@ def combat_flee(session_id: str):
|
||||
result = response.get('result', {})
|
||||
|
||||
if result.get('success'):
|
||||
# Flee successful - redirect to play page
|
||||
return redirect(url_for('game.play_session', session_id=session_id))
|
||||
# Flee successful - use HX-Redirect for HTMX
|
||||
resp = make_response(f'''
|
||||
<div class="combat-log__entry combat-log__entry--system">
|
||||
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
|
||||
</div>
|
||||
''')
|
||||
resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id)
|
||||
return resp
|
||||
else:
|
||||
# Flee failed - return log entry
|
||||
return f'''
|
||||
# Flee failed - return log entry, trigger enemy turn
|
||||
resp = make_response(f'''
|
||||
<div class="combat-log__entry combat-log__entry--system">
|
||||
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
|
||||
</div>
|
||||
'''
|
||||
''')
|
||||
# Failed flee consumes turn, so trigger enemy turn if needed
|
||||
if not result.get('next_is_player', True):
|
||||
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
logger.error("flee_failed", session_id=session_id, error=str(e))
|
||||
@@ -468,18 +480,19 @@ def combat_enemy_turn(session_id: str):
|
||||
)
|
||||
|
||||
# Format enemy action for log
|
||||
action_result = result.get('action_result', {})
|
||||
# API returns ActionResult directly in result, not nested under action_result
|
||||
log_entries = [{
|
||||
'actor': action_result.get('actor_name', 'Enemy'),
|
||||
'message': action_result.get('message', 'attacks'),
|
||||
'actor': 'Enemy',
|
||||
'message': result.get('message', 'attacks'),
|
||||
'type': 'enemy',
|
||||
'is_crit': action_result.get('is_critical', False)
|
||||
'is_crit': False
|
||||
}]
|
||||
|
||||
# Add damage info
|
||||
damage_results = action_result.get('damage_results', [])
|
||||
# Add damage info - API returns total_damage, not damage
|
||||
damage_results = result.get('damage_results', [])
|
||||
if damage_results:
|
||||
log_entries[0]['damage'] = damage_results[0].get('damage')
|
||||
log_entries[0]['damage'] = damage_results[0].get('total_damage')
|
||||
log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False)
|
||||
|
||||
# Check if it's still enemy turn (multiple enemies)
|
||||
resp = make_response(render_template(
|
||||
|
||||
@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
|
||||
DEFAULT_ACTIONS = {
|
||||
'free': [
|
||||
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
|
||||
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
|
||||
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
|
||||
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
|
||||
],
|
||||
@@ -718,6 +717,243 @@ def do_travel(session_id: str):
|
||||
return f'<div class="error">Travel failed: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/monster-modal')
|
||||
@require_auth
|
||||
def monster_modal(session_id: str):
|
||||
"""
|
||||
Get monster selection modal with encounter options.
|
||||
|
||||
Fetches random encounter groups appropriate for the current location
|
||||
and character level from the API.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
# Get encounter options from API
|
||||
response = client.get(f'/api/v1/combat/encounters?session_id={session_id}')
|
||||
result = response.get('result', {})
|
||||
|
||||
location_name = result.get('location_name', 'Unknown Area')
|
||||
encounters = result.get('encounters', [])
|
||||
|
||||
return render_template(
|
||||
'game/partials/monster_modal.html',
|
||||
session_id=session_id,
|
||||
location_name=location_name,
|
||||
encounters=encounters
|
||||
)
|
||||
|
||||
except APINotFoundError as e:
|
||||
# No enemies found for this location
|
||||
return render_template(
|
||||
'game/partials/monster_modal.html',
|
||||
session_id=session_id,
|
||||
location_name='this area',
|
||||
encounters=[]
|
||||
)
|
||||
except APIError as e:
|
||||
logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e))
|
||||
return f'''
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">⚔️ Search for Monsters</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="error">Failed to search for monsters: {e}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_combat(session_id: str):
|
||||
"""
|
||||
Start combat with selected enemies.
|
||||
|
||||
Called when player selects an encounter from the monster modal.
|
||||
Initiates combat via API and redirects to combat UI.
|
||||
|
||||
If there's already an active combat session, shows a conflict modal
|
||||
allowing the user to resume or abandon the existing combat.
|
||||
"""
|
||||
from flask import make_response
|
||||
|
||||
client = get_api_client()
|
||||
|
||||
# Get enemy_ids from request
|
||||
# HTMX hx-vals sends as form data (not JSON), where arrays become multiple values
|
||||
if request.is_json:
|
||||
enemy_ids = request.json.get('enemy_ids', [])
|
||||
else:
|
||||
# Form data: array values come as multiple entries with the same key
|
||||
enemy_ids = request.form.getlist('enemy_ids')
|
||||
|
||||
if not enemy_ids:
|
||||
return '<div class="error">No enemies selected.</div>', 400
|
||||
|
||||
try:
|
||||
# Start combat via API
|
||||
response = client.post('/api/v1/combat/start', {
|
||||
'session_id': session_id,
|
||||
'enemy_ids': enemy_ids
|
||||
})
|
||||
result = response.get('result', {})
|
||||
encounter_id = result.get('encounter_id')
|
||||
|
||||
if not encounter_id:
|
||||
logger.error("combat_start_no_encounter_id", session_id=session_id)
|
||||
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||
|
||||
logger.info("combat_started_from_modal",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter_id,
|
||||
enemy_count=len(enemy_ids))
|
||||
|
||||
# Close modal and redirect to combat page
|
||||
resp = make_response('')
|
||||
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
# Check if this is an "already in combat" error
|
||||
error_str = str(e)
|
||||
if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str:
|
||||
# Fetch existing combat info and show conflict modal
|
||||
try:
|
||||
check_response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||
combat_info = check_response.get('result', {})
|
||||
|
||||
if combat_info.get('has_active_combat'):
|
||||
return render_template(
|
||||
'game/partials/combat_conflict_modal.html',
|
||||
session_id=session_id,
|
||||
combat_info=combat_info,
|
||||
pending_enemy_ids=enemy_ids
|
||||
)
|
||||
except APIError:
|
||||
pass # Fall through to generic error
|
||||
|
||||
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
|
||||
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/check', methods=['GET'])
|
||||
@require_auth
|
||||
def check_combat_status(session_id: str):
|
||||
"""
|
||||
Check if the session has an active combat.
|
||||
|
||||
Returns JSON with combat status that can be used by HTMX
|
||||
to decide whether to show the monster modal or conflict modal.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||
result = response.get('result', {})
|
||||
return result
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_check_combat", session_id=session_id, error=str(e))
|
||||
return {'has_active_combat': False, 'error': str(e)}
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/abandon', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_combat(session_id: str):
|
||||
"""
|
||||
Abandon an existing combat session.
|
||||
|
||||
Called when player chooses to abandon their current combat
|
||||
in order to start a fresh one.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||
result = response.get('result', {})
|
||||
|
||||
if result.get('success'):
|
||||
logger.info("combat_abandoned", session_id=session_id)
|
||||
# Return success - the frontend will then try to start new combat
|
||||
return render_template(
|
||||
'game/partials/combat_abandoned_success.html',
|
||||
session_id=session_id,
|
||||
message="Combat abandoned. You can now start a new encounter."
|
||||
)
|
||||
else:
|
||||
return '<div class="error">No active combat to abandon.</div>', 400
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
|
||||
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/abandon-and-start', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_and_start_combat(session_id: str):
|
||||
"""
|
||||
Abandon existing combat and start a new one in a single action.
|
||||
|
||||
This is a convenience endpoint that combines abandon + start
|
||||
for a smoother user experience in the conflict modal.
|
||||
"""
|
||||
from flask import make_response
|
||||
|
||||
client = get_api_client()
|
||||
|
||||
# Get enemy_ids from request
|
||||
if request.is_json:
|
||||
enemy_ids = request.json.get('enemy_ids', [])
|
||||
else:
|
||||
enemy_ids = request.form.getlist('enemy_ids')
|
||||
|
||||
if not enemy_ids:
|
||||
return '<div class="error">No enemies selected.</div>', 400
|
||||
|
||||
try:
|
||||
# First abandon the existing combat
|
||||
abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||
abandon_result = abandon_response.get('result', {})
|
||||
|
||||
if not abandon_result.get('success'):
|
||||
# No combat to abandon, but that's fine - proceed with start
|
||||
logger.info("no_combat_to_abandon", session_id=session_id)
|
||||
|
||||
# Now start the new combat
|
||||
start_response = client.post('/api/v1/combat/start', {
|
||||
'session_id': session_id,
|
||||
'enemy_ids': enemy_ids
|
||||
})
|
||||
result = start_response.get('result', {})
|
||||
encounter_id = result.get('encounter_id')
|
||||
|
||||
if not encounter_id:
|
||||
logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id)
|
||||
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||
|
||||
logger.info("combat_started_after_abandon",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter_id,
|
||||
enemy_count=len(enemy_ids))
|
||||
|
||||
# Close modal and redirect to combat page
|
||||
resp = make_response('')
|
||||
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e))
|
||||
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
||||
@require_auth
|
||||
def npc_chat_page(session_id: str, npc_id: str):
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
--combat-system: var(--text-muted); /* Gray for system messages */
|
||||
--combat-heal: var(--accent-green); /* Green for healing */
|
||||
|
||||
/* Resource bar colors */
|
||||
--hp-bar-fill: #ef4444; /* Red for HP */
|
||||
--mp-bar-fill: #3b82f6; /* Blue for MP */
|
||||
|
||||
/* Combat panel sizing */
|
||||
--combat-sidebar-width: 280px;
|
||||
--combat-header-height: 60px;
|
||||
|
||||
@@ -1119,6 +1119,161 @@
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Monster Selection Modal */
|
||||
.monster-modal {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.monster-modal-location {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.monster-modal-hint {
|
||||
color: var(--text-muted);
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.encounter-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.encounter-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--play-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-left: 4px solid var(--text-muted);
|
||||
}
|
||||
|
||||
.encounter-option:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Challenge level border colors */
|
||||
.encounter-option--easy {
|
||||
border-left-color: #2ecc71; /* Green for easy */
|
||||
}
|
||||
|
||||
.encounter-option--easy:hover {
|
||||
border-color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
}
|
||||
|
||||
.encounter-option--medium {
|
||||
border-left-color: #f39c12; /* Gold/orange for medium */
|
||||
}
|
||||
|
||||
.encounter-option--medium:hover {
|
||||
border-color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.1);
|
||||
}
|
||||
|
||||
.encounter-option--hard {
|
||||
border-left-color: #e74c3c; /* Red for hard */
|
||||
}
|
||||
|
||||
.encounter-option--hard:hover {
|
||||
border-color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
.encounter-option--boss {
|
||||
border-left-color: #9b59b6; /* Purple for boss */
|
||||
}
|
||||
|
||||
.encounter-option--boss:hover {
|
||||
border-color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
}
|
||||
|
||||
.encounter-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.encounter-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.encounter-enemies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.enemy-badge {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.encounter-challenge {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.challenge--easy {
|
||||
color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
}
|
||||
|
||||
.challenge--medium {
|
||||
color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.15);
|
||||
}
|
||||
|
||||
.challenge--hard {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
}
|
||||
|
||||
.challenge--boss {
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
}
|
||||
|
||||
.encounter-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.encounter-empty p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Combat action button highlight */
|
||||
.action-btn--combat {
|
||||
background: linear-gradient(135deg, rgba(231, 76, 60, 0.2), rgba(155, 89, 182, 0.2));
|
||||
border-color: rgba(231, 76, 60, 0.4);
|
||||
}
|
||||
|
||||
.action-btn--combat:hover {
|
||||
background: linear-gradient(135deg, rgba(231, 76, 60, 0.3), rgba(155, 89, 182, 0.3));
|
||||
border-color: rgba(231, 76, 60, 0.6);
|
||||
}
|
||||
|
||||
/* NPC Chat Modal */
|
||||
.npc-chat-header {
|
||||
display: flex;
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="effect-name">{{ effect.name }}</span>
|
||||
<span class="effect-duration">{{ effect.remaining_duration }} {% if effect.remaining_duration == 1 %}turn{% else %}turns{% endif %}</span>
|
||||
<span class="effect-duration">{{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -206,43 +206,73 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Guard against duplicate enemy turn requests
|
||||
// Enemy turn handling with proper chaining for multiple enemies
|
||||
let enemyTurnPending = false;
|
||||
let enemyTurnTimeout = null;
|
||||
|
||||
function triggerEnemyTurn() {
|
||||
// Prevent duplicate requests
|
||||
if (enemyTurnPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout
|
||||
if (enemyTurnTimeout) {
|
||||
clearTimeout(enemyTurnTimeout);
|
||||
}
|
||||
|
||||
enemyTurnPending = true;
|
||||
enemyTurnTimeout = setTimeout(function() {
|
||||
htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
|
||||
target: '#combat-log',
|
||||
swap: 'beforeend'
|
||||
}).then(function() {
|
||||
|
||||
setTimeout(function() {
|
||||
// Use fetch instead of htmx.ajax for better control over response handling
|
||||
fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'HX-Request': 'true'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(response) {
|
||||
const hasMoreEnemies = response.headers.get('HX-Trigger')?.includes('enemyTurn');
|
||||
return response.text().then(function(html) {
|
||||
return { html: html, hasMoreEnemies: hasMoreEnemies };
|
||||
});
|
||||
})
|
||||
.then(function(data) {
|
||||
// Append the log entry
|
||||
const combatLog = document.getElementById('combat-log');
|
||||
if (combatLog) {
|
||||
combatLog.insertAdjacentHTML('beforeend', data.html);
|
||||
combatLog.scrollTop = combatLog.scrollHeight;
|
||||
}
|
||||
|
||||
enemyTurnPending = false;
|
||||
}).catch(function() {
|
||||
|
||||
if (data.hasMoreEnemies) {
|
||||
// More enemies to go - trigger next enemy turn
|
||||
triggerEnemyTurn();
|
||||
} else {
|
||||
// All enemies done - refresh page to update UI
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Enemy turn failed:', error);
|
||||
enemyTurnPending = false;
|
||||
// Refresh anyway to recover from error state
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Handle enemy turn polling
|
||||
// Handle player action triggering enemy turn
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check if we need to trigger enemy turn
|
||||
const response = event.detail.xhr;
|
||||
if (response && response.getResponseHeader('HX-Trigger')) {
|
||||
const triggers = response.getResponseHeader('HX-Trigger');
|
||||
if (triggers && triggers.includes('enemyTurn')) {
|
||||
triggerEnemyTurn();
|
||||
}
|
||||
if (!response) return;
|
||||
|
||||
const triggers = response.getResponseHeader('HX-Trigger') || '';
|
||||
|
||||
// Only trigger enemy turn from player actions (not from our fetch calls)
|
||||
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
|
||||
triggerEnemyTurn();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,5 +284,13 @@
|
||||
// Let the full page swap happen for victory/defeat screen
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-trigger enemy turn on page load if it's not the player's turn
|
||||
{% if not is_player_turn %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Small delay to let the page render first
|
||||
triggerEnemyTurn();
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -119,6 +119,14 @@ Displays character stats, resource bars, and action buttons
|
||||
hx-swap="innerHTML">
|
||||
🗺️ Travel to...
|
||||
</button>
|
||||
|
||||
{# Search for Monsters - Opens modal with encounter options #}
|
||||
<button class="action-btn action-btn--special action-btn--combat"
|
||||
hx-get="{{ url_for('game.monster_modal', session_id=session_id) }}"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML">
|
||||
⚔️ Search for Monsters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Actions Section #}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{#
|
||||
Combat Abandoned Success Message
|
||||
Shows after successfully abandoning a combat session
|
||||
#}
|
||||
<div class="combat-abandoned-success">
|
||||
<div class="success-icon">✔</div>
|
||||
<p class="success-message">{{ message }}</p>
|
||||
<p class="success-hint">
|
||||
<small>Click "Search for Monsters" to find a new encounter.</small>
|
||||
</p>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.combat-abandoned-success {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--color-success, #28a745);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text, #e5e7eb);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-hint {
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -50,8 +50,8 @@
|
||||
{# Flee Button - Direct action #}
|
||||
<button class="combat-action-btn combat-action-btn--flee"
|
||||
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
|
||||
hx-target="body"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#combat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-disabled-elt="this"
|
||||
hx-confirm="Are you sure you want to flee from combat?"
|
||||
title="Attempt to escape from battle">
|
||||
|
||||
220
public_web/templates/game/partials/combat_conflict_modal.html
Normal file
220
public_web/templates/game/partials/combat_conflict_modal.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{#
|
||||
Combat Conflict Modal
|
||||
Shows when player tries to start combat but already has an active combat session
|
||||
#}
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal-content combat-conflict-modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Active Combat Detected</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="conflict-warning">
|
||||
<p>You have an active combat session in progress:</p>
|
||||
</div>
|
||||
|
||||
<div class="combat-summary">
|
||||
<div class="combat-summary-header">
|
||||
<span class="combat-round">Round {{ combat_info.round_number }}</span>
|
||||
<span class="combat-status combat-status--{{ combat_info.status }}">{{ combat_info.status|capitalize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="combatants-section">
|
||||
<div class="combatants-group combatants-group--players">
|
||||
<h4>Your Party</h4>
|
||||
{% for player in combat_info.players %}
|
||||
<div class="combatant-summary {% if not player.is_alive %}combatant-summary--dead{% endif %}">
|
||||
<span class="combatant-name">{{ player.name }}</span>
|
||||
<span class="combatant-hp">
|
||||
{{ player.current_hp }}/{{ player.max_hp }} HP
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="combatants-vs">VS</div>
|
||||
|
||||
<div class="combatants-group combatants-group--enemies">
|
||||
<h4>Enemies</h4>
|
||||
{% for enemy in combat_info.enemies %}
|
||||
<div class="combatant-summary {% if not enemy.is_alive %}combatant-summary--dead{% endif %}">
|
||||
<span class="combatant-name">{{ enemy.name }}</span>
|
||||
<span class="combatant-hp">
|
||||
{% if enemy.is_alive %}
|
||||
{{ enemy.current_hp }}/{{ enemy.max_hp }} HP
|
||||
{% else %}
|
||||
Defeated
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="conflict-options">
|
||||
<p>What would you like to do?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer conflict-actions">
|
||||
<a href="{{ url_for('combat.combat_view', session_id=session_id) }}"
|
||||
class="btn btn-primary btn-resume">
|
||||
Resume Combat
|
||||
</a>
|
||||
<button class="btn btn-danger btn-abandon"
|
||||
hx-post="{{ url_for('game.abandon_and_start_combat', session_id=session_id) }}"
|
||||
hx-vals='{"enemy_ids": {{ pending_enemy_ids|tojson }}}'
|
||||
hx-swap="none"
|
||||
hx-confirm="Are you sure you want to abandon your current combat? You will not receive any rewards.">
|
||||
Abandon & Start New
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.combat-conflict-modal {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.conflict-warning {
|
||||
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
|
||||
border: 1px solid var(--color-warning, #ffc107);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conflict-warning p {
|
||||
margin: 0;
|
||||
color: var(--color-warning, #ffc107);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.combat-summary {
|
||||
background: var(--color-surface, #2a2a2a);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.combat-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #444);
|
||||
}
|
||||
|
||||
.combat-round {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.combat-status {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.combat-status--active {
|
||||
background: var(--color-success-bg, rgba(40, 167, 69, 0.2));
|
||||
color: var(--color-success, #28a745);
|
||||
}
|
||||
|
||||
.combatants-section {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.combatants-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.combatants-group h4 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.combatants-vs {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-weight: 600;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.combatant-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--color-bg, #1a1a1a);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.combatant-summary--dead {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.combatant-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.combatant-hp {
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.combatants-group--players .combatant-hp {
|
||||
color: var(--color-health, #4ade80);
|
||||
}
|
||||
|
||||
.combatants-group--enemies .combatant-hp {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.conflict-options {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.conflict-options p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conflict-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conflict-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn-resume {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
|
||||
.btn-abandon {
|
||||
background: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.btn-abandon:hover {
|
||||
background: var(--color-danger-hover, #dc2626);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{# Combat Defeat Partial - Swapped into combat log when player loses #}
|
||||
|
||||
{% block title %}Defeated - Code of Conquest{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="combat-result combat-result--defeat">
|
||||
<div class="combat-result__icon">💀</div>
|
||||
<h1 class="combat-result__title">Defeated</h1>
|
||||
@@ -52,4 +45,3 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{# Combat Victory Partial - Swapped into combat log when player wins #}
|
||||
|
||||
{% block title %}Victory! - Code of Conquest{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="combat-result combat-result--victory">
|
||||
<div class="combat-result__icon">🏆</div>
|
||||
<h1 class="combat-result__title">Victory!</h1>
|
||||
@@ -47,12 +40,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Loot Items #}
|
||||
{% if rewards.items %}
|
||||
{# Loot Items - use bracket notation to avoid conflict with dict.items() method #}
|
||||
{% if rewards.get('items') %}
|
||||
<div class="loot-section">
|
||||
<h3 class="loot-title">Items Obtained</h3>
|
||||
<div class="loot-list">
|
||||
{% for item in rewards.items %}
|
||||
{% for item in rewards.get('items', []) %}
|
||||
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
|
||||
<span>
|
||||
{% if item.type == 'weapon' %}⚔
|
||||
@@ -63,7 +56,7 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
<span>{{ item.name }}</span>
|
||||
{% if item.quantity > 1 %}
|
||||
{% if item.get('quantity', 1) > 1 %}
|
||||
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -81,4 +74,3 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
53
public_web/templates/game/partials/monster_modal.html
Normal file
53
public_web/templates/game/partials/monster_modal.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{#
|
||||
Monster Selection Modal
|
||||
Shows random encounter options for the current location
|
||||
#}
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal-content monster-modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">⚔️ Search for Monsters</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="monster-modal-location">
|
||||
Searching near <strong>{{ location_name }}</strong>...
|
||||
</p>
|
||||
{% if encounters %}
|
||||
<div class="encounter-options">
|
||||
{% for enc in encounters %}
|
||||
<button class="encounter-option encounter-option--{{ enc.challenge|lower }}"
|
||||
hx-post="{{ url_for('game.start_combat', session_id=session_id) }}"
|
||||
hx-vals='{"enemy_ids": {{ enc.enemies|tojson }}}'
|
||||
hx-target="closest .modal-overlay"
|
||||
hx-swap="outerHTML">
|
||||
<div class="encounter-info">
|
||||
<div class="encounter-name">{{ enc.display_name }}</div>
|
||||
<div class="encounter-enemies">
|
||||
{% for name in enc.enemy_names %}
|
||||
<span class="enemy-badge">{{ name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="encounter-challenge challenge--{{ enc.challenge|lower }}">
|
||||
{{ enc.challenge }}
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="monster-modal-hint">
|
||||
<small>Select an encounter to begin combat. Challenge level indicates difficulty.</small>
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="encounter-empty">
|
||||
<p>No monsters found in this area.</p>
|
||||
<p><small>Try exploring somewhere more dangerous!</small></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user