NPC shop implimented
This commit is contained in:
@@ -119,7 +119,8 @@ def _build_character_from_api(char_data: dict) -> dict:
|
||||
'equipped': char_data.get('equipped', {}),
|
||||
'inventory': char_data.get('inventory', []),
|
||||
'gold': char_data.get('gold', 0),
|
||||
'experience': char_data.get('experience', 0)
|
||||
'experience': char_data.get('experience', 0),
|
||||
'unlocked_skills': char_data.get('unlocked_skills', [])
|
||||
}
|
||||
|
||||
|
||||
@@ -1373,3 +1374,215 @@ def talk_to_npc(session_id: str, npc_id: str):
|
||||
except APIError as e:
|
||||
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
|
||||
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
|
||||
|
||||
|
||||
# ===== Shop Routes =====
|
||||
|
||||
@game_bp.route('/session/<session_id>/shop-modal')
|
||||
@require_auth
|
||||
def shop_modal(session_id: str):
|
||||
"""
|
||||
Get shop modal for browsing and purchasing items.
|
||||
|
||||
Supports filtering by item type via ?filter= parameter.
|
||||
Uses the general_store shop.
|
||||
"""
|
||||
client = get_api_client()
|
||||
filter_type = request.args.get('filter', 'all')
|
||||
message = request.args.get('message', '')
|
||||
error = request.args.get('error', '')
|
||||
|
||||
try:
|
||||
# Get session to find character
|
||||
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||
session_data = session_response.get('result', {})
|
||||
character_id = session_data.get('character_id')
|
||||
|
||||
gold = 0
|
||||
inventory = []
|
||||
shop = {}
|
||||
|
||||
if character_id:
|
||||
try:
|
||||
# Get shop inventory with character context (for affordability)
|
||||
shop_response = client.get(
|
||||
f'/api/v1/shop/general_store/inventory',
|
||||
params={'character_id': character_id}
|
||||
)
|
||||
shop_data = shop_response.get('result', {})
|
||||
shop = shop_data.get('shop', {})
|
||||
inventory = shop_data.get('inventory', [])
|
||||
|
||||
# Get character gold
|
||||
char_data = shop_data.get('character', {})
|
||||
gold = char_data.get('gold', 0)
|
||||
|
||||
except (APINotFoundError, APIError) as e:
|
||||
logger.warning("failed_to_load_shop", character_id=character_id, error=str(e))
|
||||
error = "Failed to load shop inventory"
|
||||
|
||||
# Filter inventory by type if specified
|
||||
if filter_type != 'all':
|
||||
inventory = [
|
||||
entry for entry in inventory
|
||||
if entry.get('item', {}).get('item_type') == filter_type
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'game/partials/shop_modal.html',
|
||||
session_id=session_id,
|
||||
shop=shop,
|
||||
inventory=inventory,
|
||||
gold=gold,
|
||||
filter=filter_type,
|
||||
message=message,
|
||||
error=error
|
||||
)
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_load_shop_modal", session_id=session_id, error=str(e))
|
||||
return f'''
|
||||
<div class="modal-overlay" onclick="closeModal()">
|
||||
<div class="modal-content shop-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Shop</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shop-empty">Failed to load shop: {e}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/shop/purchase', methods=['POST'])
|
||||
@require_auth
|
||||
def shop_purchase(session_id: str):
|
||||
"""
|
||||
Purchase an item from the shop.
|
||||
|
||||
HTMX endpoint - returns updated shop modal.
|
||||
"""
|
||||
client = get_api_client()
|
||||
item_id = request.form.get('item_id')
|
||||
quantity = int(request.form.get('quantity', 1))
|
||||
|
||||
try:
|
||||
# Get session to find character
|
||||
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||
session_data = session_response.get('result', {})
|
||||
character_id = session_data.get('character_id')
|
||||
|
||||
if not character_id:
|
||||
return shop_modal_with_error(session_id, "No character found for this session")
|
||||
|
||||
if not item_id:
|
||||
return shop_modal_with_error(session_id, "No item specified")
|
||||
|
||||
# Attempt purchase
|
||||
purchase_data = {
|
||||
'character_id': character_id,
|
||||
'item_id': item_id,
|
||||
'quantity': quantity,
|
||||
'session_id': session_id
|
||||
}
|
||||
|
||||
response = client.post('/api/v1/shop/general_store/purchase', json=purchase_data)
|
||||
result = response.get('result', {})
|
||||
|
||||
# Get item name for message
|
||||
purchase_info = result.get('purchase', {})
|
||||
item_name = purchase_info.get('item_id', item_id)
|
||||
total_cost = purchase_info.get('total_cost', 0)
|
||||
|
||||
message = f"Purchased {item_name} for {total_cost} gold!"
|
||||
|
||||
logger.info(
|
||||
"shop_purchase_success",
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
total_cost=total_cost
|
||||
)
|
||||
|
||||
# Re-render shop modal with success message
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
|
||||
|
||||
except APIError as e:
|
||||
logger.error(
|
||||
"shop_purchase_failed",
|
||||
session_id=session_id,
|
||||
item_id=item_id,
|
||||
error=str(e)
|
||||
)
|
||||
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/shop/sell', methods=['POST'])
|
||||
@require_auth
|
||||
def shop_sell(session_id: str):
|
||||
"""
|
||||
Sell an item to the shop.
|
||||
|
||||
HTMX endpoint - returns updated shop modal.
|
||||
"""
|
||||
client = get_api_client()
|
||||
item_instance_id = request.form.get('item_instance_id')
|
||||
quantity = int(request.form.get('quantity', 1))
|
||||
|
||||
try:
|
||||
# Get session to find character
|
||||
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||
session_data = session_response.get('result', {})
|
||||
character_id = session_data.get('character_id')
|
||||
|
||||
if not character_id:
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, error="No character found"))
|
||||
|
||||
if not item_instance_id:
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, error="No item specified"))
|
||||
|
||||
# Attempt sale
|
||||
sale_data = {
|
||||
'character_id': character_id,
|
||||
'item_instance_id': item_instance_id,
|
||||
'quantity': quantity,
|
||||
'session_id': session_id
|
||||
}
|
||||
|
||||
response = client.post('/api/v1/shop/general_store/sell', json=sale_data)
|
||||
result = response.get('result', {})
|
||||
|
||||
sale_info = result.get('sale', {})
|
||||
item_name = sale_info.get('item_name', 'Item')
|
||||
total_earned = sale_info.get('total_earned', 0)
|
||||
|
||||
message = f"Sold {item_name} for {total_earned} gold!"
|
||||
|
||||
logger.info(
|
||||
"shop_sell_success",
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
item_instance_id=item_instance_id,
|
||||
total_earned=total_earned
|
||||
)
|
||||
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
|
||||
|
||||
except APIError as e:
|
||||
logger.error(
|
||||
"shop_sell_failed",
|
||||
session_id=session_id,
|
||||
item_instance_id=item_instance_id,
|
||||
error=str(e)
|
||||
)
|
||||
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
|
||||
|
||||
|
||||
def shop_modal_with_error(session_id: str, error: str):
|
||||
"""Helper to render shop modal with an error message."""
|
||||
return redirect(url_for('game.shop_modal', session_id=session_id, error=error))
|
||||
|
||||
404
public_web/static/css/shop.css
Normal file
404
public_web/static/css/shop.css
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Code of Conquest - Shop UI Stylesheet
|
||||
* Shop modal for browsing and purchasing items
|
||||
*/
|
||||
|
||||
/* ===== SHOP MODAL ===== */
|
||||
.shop-modal {
|
||||
max-width: 900px;
|
||||
width: 95%;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
/* ===== SHOP HEADER ===== */
|
||||
.shop-modal .modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shop-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-header-info .modal-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-keeper {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--text-muted, #707078);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.shop-gold-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-tertiary, #16161a);
|
||||
border: 1px solid var(--accent-gold, #f3a61a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.shop-gold-display .gold-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.shop-gold-display .gold-amount {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold, #f3a61a);
|
||||
}
|
||||
|
||||
/* ===== SHOP MESSAGES ===== */
|
||||
.shop-message {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-message--success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.shop-message--error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ===== SHOP TABS ===== */
|
||||
.shop-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0 1rem;
|
||||
background: var(--bg-tertiary, #16161a);
|
||||
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.shop-tabs .tab {
|
||||
min-height: 48px;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary, #a0a0a8);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.shop-tabs .tab:hover {
|
||||
color: var(--text-primary, #e5e5e5);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.shop-tabs .tab.active {
|
||||
color: var(--accent-gold, #f3a61a);
|
||||
border-bottom-color: var(--accent-gold, #f3a61a);
|
||||
}
|
||||
|
||||
/* ===== SHOP BODY ===== */
|
||||
.shop-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ===== SHOP GRID ===== */
|
||||
.shop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ===== SHOP ITEM CARD ===== */
|
||||
.shop-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
background: var(--bg-input, #1e1e24);
|
||||
border: 2px solid var(--border-primary, #3a3a45);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.shop-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Rarity borders */
|
||||
.shop-item.rarity-common { border-color: var(--rarity-common, #9ca3af); }
|
||||
.shop-item.rarity-uncommon { border-color: var(--rarity-uncommon, #22c55e); }
|
||||
.shop-item.rarity-rare { border-color: var(--rarity-rare, #3b82f6); }
|
||||
.shop-item.rarity-epic { border-color: var(--rarity-epic, #a855f7); }
|
||||
.shop-item.rarity-legendary {
|
||||
border-color: var(--rarity-legendary, #f59e0b);
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* Item Header */
|
||||
.shop-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ===== RARITY TAG ===== */
|
||||
.shop-item-rarity {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rarity-tag--common {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: var(--rarity-common, #9ca3af);
|
||||
border: 1px solid var(--rarity-common, #9ca3af);
|
||||
}
|
||||
|
||||
.rarity-tag--uncommon {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--rarity-uncommon, #22c55e);
|
||||
border: 1px solid var(--rarity-uncommon, #22c55e);
|
||||
}
|
||||
|
||||
.rarity-tag--rare {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--rarity-rare, #3b82f6);
|
||||
border: 1px solid var(--rarity-rare, #3b82f6);
|
||||
}
|
||||
|
||||
.rarity-tag--epic {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--rarity-epic, #a855f7);
|
||||
border: 1px solid var(--rarity-epic, #a855f7);
|
||||
}
|
||||
|
||||
.rarity-tag--legendary {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--rarity-legendary, #f59e0b);
|
||||
border: 1px solid var(--rarity-legendary, #f59e0b);
|
||||
}
|
||||
|
||||
.shop-item-name {
|
||||
font-family: var(--font-heading, 'Cinzel', serif);
|
||||
font-size: var(--text-base, 1rem);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e5e5e5);
|
||||
}
|
||||
|
||||
.shop-item-type {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
color: var(--text-muted, #707078);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Item Description */
|
||||
.shop-item-desc {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--text-secondary, #a0a0a8);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 0.75rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Item Stats */
|
||||
.shop-item-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.shop-item-stats .stat {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-tertiary, #16161a);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e5e5e5);
|
||||
}
|
||||
|
||||
/* Item Footer - Price and Buy */
|
||||
.shop-item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--play-border, #3a3a45);
|
||||
}
|
||||
|
||||
.shop-item-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-base, 1rem);
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #f3a61a);
|
||||
}
|
||||
|
||||
.shop-item-price .gold-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shop-item-price.unaffordable {
|
||||
color: var(--text-muted, #707078);
|
||||
}
|
||||
|
||||
/* ===== PURCHASE BUTTON ===== */
|
||||
.btn-purchase {
|
||||
padding: 0.5rem 1rem;
|
||||
min-width: 90px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-purchase--available {
|
||||
background: var(--accent-green, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purchase--available:hover {
|
||||
background: #16a34a;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.btn-purchase--disabled {
|
||||
background: var(--bg-tertiary, #16161a);
|
||||
color: var(--text-muted, #707078);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== EMPTY STATE ===== */
|
||||
.shop-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted, #707078);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== SHOP FOOTER ===== */
|
||||
.shop-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shop-footer-gold {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent-gold, #f3a61a);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-footer-gold .gold-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ===== MOBILE RESPONSIVENESS ===== */
|
||||
@media (max-width: 768px) {
|
||||
.shop-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.shop-modal .modal-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shop-header-info {
|
||||
order: 1;
|
||||
flex: 1 0 60%;
|
||||
}
|
||||
|
||||
.shop-gold-display {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.shop-modal .modal-close {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.shop-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shop-tabs {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.shop-tabs .tab {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-item-name {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media (max-width: 400px) {
|
||||
.shop-item-footer {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.shop-item-price {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-purchase {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ACCESSIBILITY ===== */
|
||||
.shop-tabs .tab:focus-visible,
|
||||
.btn-purchase:focus-visible {
|
||||
outline: 2px solid var(--accent-gold, #f3a61a);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shop-item,
|
||||
.btn-purchase {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.shop-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,15 @@ Displays character stats, resource bars, and action buttons
|
||||
⚔️ Equipment & Gear
|
||||
</button>
|
||||
|
||||
{# Shop - Opens shop modal #}
|
||||
<button class="action-btn action-btn--special"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id) }}"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML">
|
||||
<span class="action-icon">💰</span>
|
||||
Shop
|
||||
</button>
|
||||
|
||||
{# Skill Trees - Direct link to skills page #}
|
||||
<a class="action-btn action-btn--special"
|
||||
href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}">
|
||||
|
||||
180
public_web/templates/game/partials/shop_modal.html
Normal file
180
public_web/templates/game/partials/shop_modal.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{#
|
||||
Shop Modal
|
||||
Browse and purchase items from the general store
|
||||
#}
|
||||
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
|
||||
role="dialog" aria-modal="true" aria-labelledby="shop-title">
|
||||
<div class="modal-content shop-modal">
|
||||
{# Header #}
|
||||
<div class="modal-header">
|
||||
<div class="shop-header-info">
|
||||
<h2 class="modal-title" id="shop-title">
|
||||
{{ shop.shop_name|default('General Store') }}
|
||||
</h2>
|
||||
<span class="shop-keeper">{{ shop.shopkeeper_name|default('Merchant') }}</span>
|
||||
</div>
|
||||
<div class="shop-gold-display">
|
||||
<span class="gold-icon">💰</span>
|
||||
<span class="gold-amount">{{ gold }}</span>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()" aria-label="Close shop">×</button>
|
||||
</div>
|
||||
|
||||
{# Success/Error Message #}
|
||||
{% if message %}
|
||||
<div class="shop-message shop-message--success">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="shop-message shop-message--error">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Tab Filter Bar #}
|
||||
<div class="shop-tabs" role="tablist">
|
||||
<button class="tab {% if filter == 'all' %}active{% endif %}"
|
||||
role="tab"
|
||||
aria-selected="{{ 'true' if filter == 'all' else 'false' }}"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='all') }}"
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML">
|
||||
All
|
||||
</button>
|
||||
<button class="tab {% if filter == 'weapon' %}active{% endif %}"
|
||||
role="tab"
|
||||
aria-selected="{{ 'true' if filter == 'weapon' else 'false' }}"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='weapon') }}"
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML">
|
||||
Weapons
|
||||
</button>
|
||||
<button class="tab {% if filter == 'armor' %}active{% endif %}"
|
||||
role="tab"
|
||||
aria-selected="{{ 'true' if filter == 'armor' else 'false' }}"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='armor') }}"
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML">
|
||||
Armor
|
||||
</button>
|
||||
<button class="tab {% if filter == 'consumable' %}active{% endif %}"
|
||||
role="tab"
|
||||
aria-selected="{{ 'true' if filter == 'consumable' else 'false' }}"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='consumable') }}"
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML">
|
||||
Consumables
|
||||
</button>
|
||||
<button class="tab {% if filter == 'accessory' %}active{% endif %}"
|
||||
role="tab"
|
||||
aria-selected="{{ 'true' if filter == 'accessory' else 'false' }}"
|
||||
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='accessory') }}"
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML">
|
||||
Accessories
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Body - Item Grid #}
|
||||
<div class="modal-body shop-body">
|
||||
<div class="shop-grid">
|
||||
{% for entry in inventory %}
|
||||
{% set item = entry.item %}
|
||||
{% set price = entry.shop_price %}
|
||||
{% set can_afford = entry.can_afford|default(gold >= price) %}
|
||||
<div class="shop-item rarity-{{ item.rarity|default('common') }}">
|
||||
{# Item Header #}
|
||||
<div class="shop-item-header">
|
||||
<span class="shop-item-name">{{ item.name }}</span>
|
||||
<span class="shop-item-type">{{ item.item_type|default('item')|replace('_', ' ')|title }}</span>
|
||||
</div>
|
||||
|
||||
{# Rarity Tag #}
|
||||
<span class="shop-item-rarity rarity-tag--{{ item.rarity|default('common') }}">
|
||||
{{ item.rarity|default('common')|title }}
|
||||
</span>
|
||||
|
||||
{# Item Description #}
|
||||
<p class="shop-item-desc">{{ item.description|default('A useful item.')|truncate(80) }}</p>
|
||||
|
||||
{# Item Stats #}
|
||||
<div class="shop-item-stats">
|
||||
{% if item.item_type == 'weapon' %}
|
||||
{% if item.damage %}
|
||||
<span class="stat">Damage: {{ item.damage }}</span>
|
||||
{% endif %}
|
||||
{% if item.damage_type %}
|
||||
<span class="stat">{{ item.damage_type|title }}</span>
|
||||
{% endif %}
|
||||
{% elif item.item_type == 'armor' or item.item_type == 'shield' %}
|
||||
{% if item.defense %}
|
||||
<span class="stat">Defense: +{{ item.defense }}</span>
|
||||
{% endif %}
|
||||
{% if item.slot %}
|
||||
<span class="stat">{{ item.slot|replace('_', ' ')|title }}</span>
|
||||
{% endif %}
|
||||
{% elif item.item_type == 'consumable' %}
|
||||
{% if item.hp_restore %}
|
||||
<span class="stat">HP +{{ item.hp_restore }}</span>
|
||||
{% endif %}
|
||||
{% if item.mp_restore %}
|
||||
<span class="stat">MP +{{ item.mp_restore }}</span>
|
||||
{% endif %}
|
||||
{% if item.effect %}
|
||||
<span class="stat">{{ item.effect }}</span>
|
||||
{% endif %}
|
||||
{% elif item.item_type == 'accessory' %}
|
||||
{% if item.stat_bonuses %}
|
||||
{% for stat, bonus in item.stat_bonuses.items() %}
|
||||
<span class="stat">{{ stat|title }}: +{{ bonus }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Price and Buy Button #}
|
||||
<div class="shop-item-footer">
|
||||
<span class="shop-item-price {% if not can_afford %}unaffordable{% endif %}">
|
||||
<span class="gold-icon">💰</span> {{ price }}
|
||||
</span>
|
||||
<button class="btn-purchase {% if can_afford %}btn-purchase--available{% else %}btn-purchase--disabled{% endif %}"
|
||||
{% if can_afford %}
|
||||
hx-post="{{ url_for('game.shop_purchase', session_id=session_id) }}"
|
||||
hx-vals='{"item_id": "{{ item.item_id }}", "quantity": 1}'
|
||||
hx-target=".shop-modal"
|
||||
hx-swap="outerHTML"
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
aria-label="{% if can_afford %}Purchase {{ item.name }} for {{ price }} gold{% else %}Not enough gold{% endif %}">
|
||||
{% if can_afford %}
|
||||
Buy
|
||||
{% else %}
|
||||
Can't Afford
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="shop-empty">
|
||||
{% if filter == 'all' %}
|
||||
No items available in this shop.
|
||||
{% else %}
|
||||
No {{ filter|replace('_', ' ') }}s available.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="modal-footer">
|
||||
<div class="shop-footer-gold">
|
||||
<span class="gold-icon">💰</span>
|
||||
<span class="gold-amount">{{ gold }} gold</span>
|
||||
</div>
|
||||
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/shop.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
Reference in New Issue
Block a user