Compare commits
4 Commits
b03ba09b65
...
9698d70164
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9698d70164 | ||
|
|
7370fd611f | ||
|
|
f5a5ed9d8d | ||
|
|
a5d71925fc |
@@ -58,6 +58,22 @@
|
||||
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
||||
});
|
||||
|
||||
function attachTooltip(el) {
|
||||
el.addEventListener('mouseenter', function () {
|
||||
var tooltip = el.querySelector('.token-tooltip');
|
||||
if (!tooltip) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
var tooltip = el.querySelector('.token-tooltip');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
function attachCardListeners() {
|
||||
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
@@ -69,22 +85,10 @@
|
||||
var slot = document.querySelector('.token-slot');
|
||||
if (slot) slot.classList.add('ready');
|
||||
});
|
||||
|
||||
card.addEventListener('mouseenter', function () {
|
||||
var tooltip = card.querySelector('.token-tooltip');
|
||||
if (!tooltip) return;
|
||||
var rect = card.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function () {
|
||||
var tooltip = card.querySelector('.token-tooltip');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
});
|
||||
attachTooltip(card);
|
||||
});
|
||||
|
||||
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ def kit_bag(request):
|
||||
)
|
||||
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||
"equipped_deck": request.user.equipped_deck,
|
||||
"equipped_trinket": request.user.equipped_trinket,
|
||||
"free_token": free_tokens[0] if free_tokens else None,
|
||||
"free_count": len(free_tokens),
|
||||
|
||||
106
src/apps/gameboard/static/apps/gameboard/game-kit.js
Normal file
106
src/apps/gameboard/static/apps/gameboard/game-kit.js
Normal file
@@ -0,0 +1,106 @@
|
||||
function initGameKitPage() {
|
||||
const dialog = document.getElementById('id_tarot_fan_dialog');
|
||||
if (!dialog) return;
|
||||
|
||||
const fanContent = document.getElementById('id_fan_content');
|
||||
const prevBtn = document.getElementById('id_fan_prev');
|
||||
const nextBtn = document.getElementById('id_fan_next');
|
||||
|
||||
let currentDeckId = null;
|
||||
let currentIndex = 0;
|
||||
let cards = [];
|
||||
|
||||
function storageKey(deckId) {
|
||||
return 'tarot-fan-' + deckId;
|
||||
}
|
||||
|
||||
function savePosition() {
|
||||
if (currentDeckId !== null) {
|
||||
sessionStorage.setItem(storageKey(currentDeckId), currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function restorePosition(deckId) {
|
||||
const saved = sessionStorage.getItem(storageKey(deckId));
|
||||
return saved !== null ? parseInt(saved, 10) : 0;
|
||||
}
|
||||
|
||||
function cardTransform(offset) {
|
||||
const abs = Math.abs(offset);
|
||||
return {
|
||||
transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
|
||||
opacity: Math.max(0.15, 1 - abs * 0.25),
|
||||
zIndex: 10 - abs,
|
||||
};
|
||||
}
|
||||
|
||||
function updateFan() {
|
||||
const total = cards.length;
|
||||
if (!total) return;
|
||||
cards.forEach(function(card, i) {
|
||||
let offset = i - currentIndex;
|
||||
if (offset > total / 2) offset -= total;
|
||||
if (offset < -total / 2) offset += total;
|
||||
|
||||
const abs = Math.abs(offset);
|
||||
card.classList.toggle('fan-card--active', offset === 0);
|
||||
|
||||
if (abs > 3) {
|
||||
card.style.display = 'none';
|
||||
} else {
|
||||
card.style.display = '';
|
||||
const t = cardTransform(offset);
|
||||
card.style.transform = t.transform;
|
||||
card.style.opacity = t.opacity;
|
||||
card.style.zIndex = t.zIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openFan(deckId) {
|
||||
currentDeckId = deckId;
|
||||
currentIndex = restorePosition(deckId);
|
||||
|
||||
fetch('/gameboard/game-kit/deck/' + deckId + '/')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
fanContent.innerHTML = html;
|
||||
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
|
||||
if (currentIndex >= cards.length) currentIndex = 0;
|
||||
updateFan();
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function closeFan() {
|
||||
savePosition();
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
function navigate(delta) {
|
||||
if (!cards.length) return;
|
||||
currentIndex = (currentIndex + delta + cards.length) % cards.length;
|
||||
savePosition();
|
||||
updateFan();
|
||||
}
|
||||
|
||||
// Click on the dialog background (outside .tarot-fan-wrap) closes the modal
|
||||
dialog.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.tarot-fan-wrap')) closeFan();
|
||||
});
|
||||
|
||||
// Arrow key navigation
|
||||
dialog.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'ArrowRight') navigate(1);
|
||||
if (e.key === 'ArrowLeft') navigate(-1);
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', function() { navigate(-1); });
|
||||
nextBtn.addEventListener('click', function() { navigate(1); });
|
||||
|
||||
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) {
|
||||
card.addEventListener('click', function() { openFan(card.dataset.deckId); });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initGameKitPage);
|
||||
@@ -118,3 +118,21 @@ class EquipTrinketViewTest(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
|
||||
|
||||
|
||||
class TarotFanViewTest(TestCase):
|
||||
def setUp(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
self.user = User.objects.create(email="fan@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_returns_fan_partial_for_unlocked_deck(self):
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
|
||||
|
||||
def test_returns_403_for_locked_deck(self):
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -8,5 +8,7 @@ urlpatterns = [
|
||||
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
||||
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
||||
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
|
||||
path('game-kit/', views.game_kit, name='game_kit'),
|
||||
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
||||
]
|
||||
|
||||
|
||||
@@ -100,3 +100,36 @@ def equip_deck(request, deck_id):
|
||||
request.user.save(update_fields=["equipped_deck"])
|
||||
return HttpResponse(status=204)
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def game_kit(request):
|
||||
coin = request.user.tokens.filter(token_type=Token.COIN).first()
|
||||
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
||||
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
|
||||
free_tokens = list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at"))
|
||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
||||
return render(request, "apps/gameboard/game_kit.html", {
|
||||
"coin": coin,
|
||||
"pass_token": pass_token,
|
||||
"carte": carte,
|
||||
"free_tokens": free_tokens,
|
||||
"tithe_tokens": tithe_tokens,
|
||||
"unlocked_decks": list(request.user.unlocked_decks.all()),
|
||||
"page_class": "page-gameboard",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def tarot_fan(request, deck_id):
|
||||
from apps.epic.models import TarotCard
|
||||
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
||||
return HttpResponse(status=403)
|
||||
cards = list(TarotCard.objects.filter(deck_variant=deck).order_by("arcana", "number"))
|
||||
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
|
||||
"deck": deck,
|
||||
"cards": cards,
|
||||
})
|
||||
|
||||
@@ -342,3 +342,163 @@ class GameKitDeckSelectionTest(FunctionalTest):
|
||||
self.browser.find_element(By.ID, "id_kit_earthman_deck")
|
||||
fiorentine_cards = self.browser.find_elements(By.ID, "id_kit_fiorentine_deck")
|
||||
self.assertEqual(len(fiorentine_cards), 0)
|
||||
|
||||
|
||||
class GameKitPageTest(FunctionalTest):
|
||||
"""
|
||||
User navigates from gameboard to the dedicated game-kit page.
|
||||
The page shows four rows: trinkets, tokens, card decks, dice placeholder.
|
||||
Clicking a deck card opens a tarot fan modal with coverflow navigation.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from apps.epic.models import TarotCard
|
||||
for slug, name, cols, rows in [
|
||||
("new-game", "New Game", 6, 3),
|
||||
("my-games", "My Games", 6, 3),
|
||||
("game-kit", "Game Kit", 6, 3),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "gameboard"},
|
||||
)
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
# Seed 10 cards — enough to demonstrate full 7-card coverflow
|
||||
for i in range(10):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=self.earthman,
|
||||
slug=f"gkp-card-{i}",
|
||||
defaults={"name": f"Card {i}", "arcana": "MAJOR", "number": i},
|
||||
)
|
||||
# Create user after decks so signal sets equipped_deck + unlocked_decks
|
||||
self.gamer = User.objects.create(email="gamer@kit.io")
|
||||
self.gamer.refresh_from_db()
|
||||
self.create_pre_authenticated_session("gamer@kit.io")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 7 — gameboard Game Kit heading links to dedicated page #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_gameboard_game_kit_heading_links_to_game_kit_page(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/")
|
||||
link = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_game_kit h2 a")
|
||||
)
|
||||
link.click()
|
||||
self.wait_for(lambda: self.assertIn("/gameboard/game-kit/", self.browser.current_url))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 8 — game-kit page shows four rows #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_game_kit_page_shows_four_rows(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_gk_trinkets"))
|
||||
self.browser.find_element(By.ID, "id_gk_tokens")
|
||||
self.browser.find_element(By.ID, "id_gk_decks")
|
||||
self.browser.find_element(By.ID, "id_gk_dice")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 9 — clicking a deck card opens the tarot fan modal #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_clicking_deck_opens_tarot_fan_modal(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
).click()
|
||||
dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog")
|
||||
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 10 — fan shows active center card plus receding cards #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_fan_shows_active_card_and_receding_cards(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
).click()
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
|
||||
visible = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_fan_content .fan-card:not([style*='display: none'])"
|
||||
)
|
||||
self.assertGreater(len(visible), 1)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 11 — next button advances the active card #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_fan_next_button_advances_card(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
).click()
|
||||
first_index = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")
|
||||
).get_attribute("data-index")
|
||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotEqual(
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
||||
first_index,
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 12 — clicking outside the modal closes it #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_clicking_outside_fan_closes_modal(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
).click()
|
||||
dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog")
|
||||
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
|
||||
# Dispatch a click directly on the dialog element (simulates clicking the dark backdrop)
|
||||
self.browser.execute_script(
|
||||
"document.getElementById('id_tarot_fan_dialog')"
|
||||
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
|
||||
)
|
||||
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 13 — reopening the modal remembers scroll position #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_fan_remembers_position_on_reopen(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
deck_card = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
)
|
||||
deck_card.click()
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
|
||||
# Advance 3 cards
|
||||
for _ in range(3):
|
||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
||||
saved_index = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index")
|
||||
)
|
||||
# Close
|
||||
self.browser.execute_script(
|
||||
"document.getElementById('id_tarot_fan_dialog')"
|
||||
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.assertFalse(
|
||||
self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed()
|
||||
)
|
||||
)
|
||||
# Reopen and verify position restored
|
||||
deck_card.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
||||
saved_index,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
@@ -62,3 +63,44 @@ class GameKitTest(FunctionalTest):
|
||||
lambda: self.browser.find_element(By.ID, "id_kit_btn")
|
||||
)
|
||||
self.assertTrue(kit_btn.is_displayed())
|
||||
|
||||
def test_kit_dialog_shows_equipped_deck(self):
|
||||
"""New user auto-gets Earthman equipped; kit bar shows its deck card."""
|
||||
self.browser.get(self.gate_url)
|
||||
self.browser.find_element(By.ID, "id_kit_btn").click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
f"#id_kit_bag_dialog .kit-bag-deck[data-deck-id='{self.gamer.equipped_deck.pk}']",
|
||||
)
|
||||
)
|
||||
|
||||
def test_kit_dialog_always_shows_dice_placeholder(self):
|
||||
self.browser.get(self.gate_url)
|
||||
self.browser.find_element(By.ID, "id_kit_btn").click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"#id_kit_bag_dialog .kit-bag-placeholder",
|
||||
)
|
||||
)
|
||||
|
||||
def test_kit_dialog_deck_tooltip_shows_name_count_availability_and_stock_version(self):
|
||||
self.browser.get(self.gate_url)
|
||||
self.browser.find_element(By.ID, "id_kit_btn").click()
|
||||
deck_el = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
|
||||
)
|
||||
)
|
||||
ActionChains(self.browser).move_to_element(deck_el).perform()
|
||||
tooltip = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
|
||||
)
|
||||
self.wait_for(lambda: self.assertTrue(tooltip.is_displayed()))
|
||||
text = tooltip.text
|
||||
self.assertIn("Earthman", text)
|
||||
self.assertIn("(Default)", text)
|
||||
self.assertIn("108", text)
|
||||
self.assertIn("active", text)
|
||||
self.assertIn("Stock version", text)
|
||||
|
||||
@@ -219,3 +219,4 @@
|
||||
#id_game_applets_container { @extend %applets-grid; }
|
||||
#id_wallet_applets_container { @extend %applets-grid; }
|
||||
#id_billboard_applets_container { @extend %applets-grid; }
|
||||
#id_game_kit_applets_container { @extend %applets-grid; }
|
||||
|
||||
@@ -102,16 +102,185 @@
|
||||
padding: 0 0.125rem;
|
||||
|
||||
&:hover .token-tooltip { display: none; } // JS positions these as fixed
|
||||
|
||||
|
||||
}
|
||||
|
||||
.token-tooltip {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.kit-bag-deck {
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
.kit-bag-placeholder {
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.3;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-bag-section--tokens {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kit-bag-row--scroll {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.kit-bag-empty {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(var(--secUser), 0.4);
|
||||
}
|
||||
|
||||
// ── Game Kit page ────────────────────────────────────────────────────────────
|
||||
|
||||
#id_game_kit_applets_container section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h2 { flex-shrink: 0; }
|
||||
.gk-items { flex: 1; overflow-y: auto; }
|
||||
}
|
||||
|
||||
.gk-items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gk-deck-card,
|
||||
.gk-trinket-card,
|
||||
.gk-token-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0.1rem solid rgba(var(--secUser), 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
min-width: 6rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
small { font-size: 0.6rem; opacity: 0.5; }
|
||||
|
||||
&:hover { border-color: rgba(var(--secUser), 0.8); }
|
||||
}
|
||||
|
||||
.gk-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0.1rem dashed rgba(var(--secUser), 0.2);
|
||||
font-size: 1.5rem;
|
||||
min-width: 6rem;
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
|
||||
span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
}
|
||||
|
||||
.gk-empty {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
// ── Tarot fan modal ──────────────────────────────────────────────────────────
|
||||
|
||||
#id_tarot_fan_dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
overflow: hidden;
|
||||
|
||||
&::backdrop { display: none; } // Dialog IS the backdrop
|
||||
}
|
||||
|
||||
.tarot-fan-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 900px;
|
||||
}
|
||||
|
||||
.tarot-fan {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.fan-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&--active {
|
||||
border-color: rgba(var(--secUser), 1);
|
||||
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.fan-card-face {
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
|
||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
|
||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }
|
||||
.fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; }
|
||||
}
|
||||
|
||||
.fan-nav {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(var(--secUser), 0.6);
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
transition: color 0.15s;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover { color: rgba(var(--secUser), 1); }
|
||||
&--prev { left: 1rem; }
|
||||
&--next { right: 1rem; }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
&.expiry {
|
||||
color: rgba(var(--priRd), 1);
|
||||
}
|
||||
|
||||
&.availability {
|
||||
color: rgba(var(--priRd), 1);
|
||||
}
|
||||
|
||||
&.stock-version {
|
||||
font-weight: 700;
|
||||
color: rgba(var(--terUser), 1);
|
||||
}
|
||||
}
|
||||
|
||||
small {
|
||||
|
||||
@@ -11,22 +11,21 @@
|
||||
// Inline scripts can run before nested flex heights are computed, producing
|
||||
// wrong scrollHeight/clientHeight values (symptom: incorrect marginTop on mobile).
|
||||
requestAnimationFrame(function() {
|
||||
// Push buffer so its top aligns with the bottom of the aperture when all
|
||||
// events fit within the viewport (no natural scrolling).
|
||||
var buffer = scroll.querySelector('.scroll-buffer');
|
||||
if (buffer) {
|
||||
// Push buffer so its top aligns with the bottom of the aperture when all
|
||||
// events fit within the viewport (no natural scrolling needed).
|
||||
var eventsHeight = scroll.scrollHeight - buffer.offsetHeight;
|
||||
var gap = scroll.clientHeight - eventsHeight;
|
||||
if (gap > 0) {
|
||||
buffer.style.marginTop = gap + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Only restore if there's a meaningful saved position — avoids a
|
||||
// no-op scrollTop assignment (0→0) that can fire a spurious scroll
|
||||
// event and reset the debounce timer in tests / headless browsers.
|
||||
if ({{ scroll_position }} > 0) {
|
||||
scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
|
||||
// Always land with the buffer's top flush against the bottom edge —
|
||||
// the user must scroll down to reveal "What happens next…?"
|
||||
// Reading offsetTop here forces a synchronous reflow so the margin
|
||||
// set above is already reflected in the value.
|
||||
scroll.scrollTop = Math.max(0, buffer.offsetTop - scroll.clientHeight);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id="id_applet_game_kit"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>Game Kit</h2>
|
||||
<h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
|
||||
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}" data-equipped-deck-id="{{ equipped_deck_id }}">
|
||||
{% if pass_token %}
|
||||
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
|
||||
|
||||
15
src/templates/apps/gameboard/_partials/_tarot_fan.html
Normal file
15
src/templates/apps/gameboard/_partials/_tarot_fan.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% for card in cards %}
|
||||
<div class="fan-card" data-index="{{ forloop.counter0 }}">
|
||||
<div class="fan-card-face">
|
||||
<p class="fan-card-number">{{ card.number }}</p>
|
||||
<h3 class="fan-card-name">{{ card.name }}</h3>
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
{% if card.correspondence %}
|
||||
<p class="fan-card-correspondence">{{ card.correspondence }}</p>
|
||||
{% endif %}
|
||||
{% if card.suit %}
|
||||
<p class="fan-card-suit">{{ card.suit }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
98
src/templates/apps/gameboard/game_kit.html
Normal file
98
src/templates/apps/gameboard/game_kit.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Game Kit{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span>Kit{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="gameboard-page">
|
||||
<div id="id_game_kit_applets_container">
|
||||
|
||||
<section id="id_gk_trinkets" style="--applet-cols: 6; --applet-rows: 3;">
|
||||
<h2>Trinkets</h2>
|
||||
<div class="gk-items">
|
||||
{% if pass_token %}
|
||||
<div class="gk-trinket-card" data-token-id="{{ pass_token.pk }}">
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
<span>{{ pass_token.tooltip_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if carte %}
|
||||
<div class="gk-trinket-card" data-token-id="{{ carte.pk }}">
|
||||
<i class="fa-solid fa-money-check"></i>
|
||||
<span>{{ carte.tooltip_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if coin %}
|
||||
<div class="gk-trinket-card" data-token-id="{{ coin.pk }}">
|
||||
<i class="fa-solid fa-medal"></i>
|
||||
<span>{{ coin.tooltip_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not pass_token and not carte and not coin %}
|
||||
<p class="gk-empty"><em>No trinkets yet.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="id_gk_tokens" style="--applet-cols: 6; --applet-rows: 3;">
|
||||
<h2>Tokens</h2>
|
||||
<div class="gk-items">
|
||||
{% for token in free_tokens %}
|
||||
<div class="gk-token-card" data-token-id="{{ token.pk }}">
|
||||
<i class="fa-solid fa-coins"></i>
|
||||
<span>{{ token.tooltip_name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for token in tithe_tokens %}
|
||||
<div class="gk-token-card" data-token-id="{{ token.pk }}">
|
||||
<i class="fa-solid fa-hand-holding-dollar"></i>
|
||||
<span>{{ token.tooltip_name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not free_tokens and not tithe_tokens %}
|
||||
<p class="gk-empty"><em>No tokens yet.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="id_gk_decks" style="--applet-cols: 6; --applet-rows: 3;">
|
||||
<h2>Card Decks</h2>
|
||||
<div class="gk-items">
|
||||
{% for deck in unlocked_decks %}
|
||||
<div class="gk-deck-card" data-deck-id="{{ deck.pk }}">
|
||||
<i class="fa-regular fa-id-badge"></i>
|
||||
<span>{{ deck.name }}</span>
|
||||
<small>{{ deck.card_count }} cards</small>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="gk-empty"><em>No decks unlocked.</em></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="id_gk_dice" style="--applet-cols: 6; --applet-rows: 3;">
|
||||
<h2>Dice Sets</h2>
|
||||
<div class="gk-items">
|
||||
<div class="gk-placeholder">
|
||||
<i class="fa-solid fa-dice"></i>
|
||||
<span>Coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="id_tarot_fan_dialog">
|
||||
<div class="tarot-fan-wrap">
|
||||
<button id="id_fan_prev" class="fan-nav fan-nav--prev" aria-label="Previous card">‹</button>
|
||||
<div id="id_fan_content" class="tarot-fan"></div>
|
||||
<button id="id_fan_next" class="fan-nav fan-nav--next" aria-label="Next card">›</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/gameboard/game-kit.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
@@ -1,8 +1,34 @@
|
||||
{% if equipped_trinket or free_token or tithe_token %}
|
||||
{% if equipped_deck %}
|
||||
<div class="kit-bag-section">
|
||||
<span class="kit-bag-label">Trinkets</span>
|
||||
<span class="kit-bag-label">Deck</span>
|
||||
<div class="kit-bag-row">
|
||||
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
|
||||
<i class="fa-regular fa-id-badge"></i>
|
||||
<div class="token-tooltip">
|
||||
<h4>{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
|
||||
<p>{{ equipped_deck.card_count }}-card Tarot deck</p>
|
||||
<small><em>placeholder comment</em></small>
|
||||
<p class="availability">active</p>
|
||||
<p class="stock-version">Stock version</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="kit-bag-section">
|
||||
<span class="kit-bag-label">Dice</span>
|
||||
<div class="kit-bag-row">
|
||||
<div class="kit-bag-placeholder">
|
||||
<i class="fa-solid fa-dice"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if equipped_trinket %}
|
||||
<div class="kit-bag-section">
|
||||
<span class="kit-bag-label">Trinket</span>
|
||||
<div class="kit-bag-row">
|
||||
{% if equipped_trinket %}
|
||||
{% with token=equipped_trinket %}
|
||||
<div
|
||||
class="token"
|
||||
@@ -30,12 +56,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kit-bag-section">
|
||||
{% endif %}
|
||||
|
||||
{% if free_token or tithe_token %}
|
||||
<div class="kit-bag-section kit-bag-section--tokens">
|
||||
<span class="kit-bag-label">Tokens</span>
|
||||
<div class="kit-bag-row">
|
||||
<div class="kit-bag-row kit-bag-row--scroll">
|
||||
{% if free_token %}
|
||||
<div
|
||||
class="token"
|
||||
@@ -71,6 +99,4 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="kit-bag-empty">Kit bag empty.</p>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user