Game Kit fan stage + FLIP/SPIN; sig/sea/fan refactor — TDD
- fan modal: stage block w. idle-reveal/careen-out; carousel shifts left so focused card sits left-of-center; SPIN rotates whole card via Element.animate(); FLIP toggles polarity (Levity ↔ Gravity) via perspective rotateY w. mid-flip repaint; SPIN state retained across FLIP; FLIP btn hover-revealed only when focused card or btn is hovered (:has) - mobile breakpoints: --fan-card-w / --fan-card-h / --fan-stage-shift / --fan-carousel-step lifted to CSS vars on .tarot-fan-wrap; portrait ≤ 480px @ 150×230, landscape ≤ 500h @ 150×235; corners + face text/padding scale w. card width - shared StageCard JS module (apps/epic/stage-card.js): fromDataset, populateCard, populateKeywords, buildInfoData, renderFyi — sig/sea/fan all delegate; ~150 lines de-duplicated - shared @mixin stat-block-shared (SCSS) lifts duplicated stat-face / stat-keywords / sig-info rules; @mixin stage-card-polarity unifies sea-stage--levity/--gravity + fan[data-polarity] coloring - model rename: TarotCard.reversal → reversal_qualifier (migration 0014); render-time fallback to current polarity's qualifier when blank - class unification: .sig-info-open / .sea-info-open / .fyi-open → .fyi-open (on stat block); .sig-flip-btn / .sea-spin-btn / .fan-spin-btn → .spin-btn; same for .fyi-btn / .fyi-prev / .fyi-next - custom combobox (apps/epic/combobox.js) replaces native <select> for PICK SEA spread picker — keyboard nav, click-outside-close, aria roles; Firefox/Chrome OS-rendered <option> ignored CSS - Jasmine: FanStageSpec.js w. idle-reveal / population / SPIN / FYI / FLIP specs; sig + sea fixtures + IT view assertions updated for renamed classes - 748 ITs + Jasmine green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Rename TarotCard.reversal → TarotCard.reversal_qualifier.
|
||||
|
||||
Symmetric naming with levity_qualifier / gravity_qualifier; disambiguates the
|
||||
qualifier-text field from the reversal *axis* state and the keywords_reversed
|
||||
list. Pure column rename — no data movement.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0013_fix_nomad_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="tarotcard",
|
||||
old_name="reversal",
|
||||
new_name="reversal_qualifier",
|
||||
),
|
||||
]
|
||||
@@ -250,7 +250,7 @@ class TarotCard(models.Model):
|
||||
slug = models.SlugField(max_length=120)
|
||||
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
|
||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||
reversal = models.CharField(max_length=200, blank=True, default='') # reversed-state title; blank = same as name
|
||||
reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # reversal-axis qualifier (e.g. "Nervous"); polarity-shared; blank = falls back to current polarity's qualifier
|
||||
levity_qualifier = models.CharField(max_length=100, blank=True, default='')
|
||||
gravity_qualifier = models.CharField(max_length=100, blank=True, default='')
|
||||
levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49)
|
||||
|
||||
123
src/apps/epic/static/apps/epic/combobox.js
Normal file
123
src/apps/epic/static/apps/epic/combobox.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// Generic CSS-stylable replacement for <select>. Native <option> rendering on
|
||||
// Firefox/Chrome is partly OS-controlled and ignores most CSS; this gives full
|
||||
// styling control via div-based listbox markup.
|
||||
//
|
||||
// Markup contract:
|
||||
// <input type="hidden" id="X" name="..." value="...">
|
||||
// <div data-combobox data-combobox-target="X"
|
||||
// role="combobox" aria-expanded="false" aria-haspopup="listbox"
|
||||
// tabindex="0">
|
||||
// <span class="sea-select-current">…visible label…</span>
|
||||
// <span class="sea-select-arrow" aria-hidden="true">▾</span>
|
||||
// <ul class="sea-select-list" role="listbox">
|
||||
// <li role="option" data-value="..." aria-selected="true|false">…</li>
|
||||
// …
|
||||
// </ul>
|
||||
// </div>
|
||||
//
|
||||
// On selection the hidden input's value is updated and a 'change' event fires
|
||||
// on it, so existing consumers (`document.getElementById('X').value`) keep working.
|
||||
var Combobox = (function () {
|
||||
'use strict';
|
||||
|
||||
function init(combo) {
|
||||
if (!combo || combo.dataset.comboboxInit) return;
|
||||
combo.dataset.comboboxInit = '1';
|
||||
|
||||
var hiddenId = combo.dataset.comboboxTarget;
|
||||
var hidden = hiddenId && document.getElementById(hiddenId);
|
||||
var current = combo.querySelector('.sea-select-current');
|
||||
var list = combo.querySelector('.sea-select-list');
|
||||
var options = list ? Array.from(list.querySelectorAll('[role="option"]')) : [];
|
||||
var focusIdx = options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
|
||||
if (focusIdx < 0) focusIdx = 0;
|
||||
|
||||
function isOpen() { return combo.getAttribute('aria-expanded') === 'true'; }
|
||||
function open() {
|
||||
combo.setAttribute('aria-expanded', 'true');
|
||||
// Re-focus the currently-selected option each time we open
|
||||
focusIdx = options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
|
||||
if (focusIdx < 0) focusIdx = 0;
|
||||
highlight();
|
||||
}
|
||||
function close() { combo.setAttribute('aria-expanded', 'false'); }
|
||||
function toggle() { isOpen() ? close() : open(); }
|
||||
|
||||
function highlight() {
|
||||
options.forEach(function (o, i) {
|
||||
o.classList.toggle('sea-select-option--focus', i === focusIdx);
|
||||
});
|
||||
}
|
||||
|
||||
function select(i) {
|
||||
if (i < 0 || i >= options.length) return;
|
||||
options.forEach(function (o, idx) {
|
||||
o.setAttribute('aria-selected', idx === i ? 'true' : 'false');
|
||||
});
|
||||
var picked = options[i];
|
||||
if (current) current.textContent = picked.textContent.trim();
|
||||
if (hidden && hidden.value !== picked.dataset.value) {
|
||||
hidden.value = picked.dataset.value;
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
combo.addEventListener('click', function (e) {
|
||||
var opt = e.target.closest('[role="option"]');
|
||||
if (opt) {
|
||||
var i = options.indexOf(opt);
|
||||
if (i >= 0) select(i);
|
||||
return;
|
||||
}
|
||||
toggle();
|
||||
});
|
||||
|
||||
combo.addEventListener('keydown', function (e) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen()) { open(); }
|
||||
else { focusIdx = Math.min(options.length - 1, focusIdx + 1); highlight(); }
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (!isOpen()) { open(); }
|
||||
else { focusIdx = Math.max(0, focusIdx - 1); highlight(); }
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (isOpen()) select(focusIdx);
|
||||
else open();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (isOpen()) { e.preventDefault(); close(); }
|
||||
break;
|
||||
case 'Home':
|
||||
if (isOpen()) { e.preventDefault(); focusIdx = 0; highlight(); }
|
||||
break;
|
||||
case 'End':
|
||||
if (isOpen()) { e.preventDefault(); focusIdx = options.length - 1; highlight(); }
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside the combo closes the dropdown
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!combo.contains(e.target)) close();
|
||||
});
|
||||
}
|
||||
|
||||
function initAll(root) {
|
||||
(root || document).querySelectorAll('[data-combobox]').forEach(init);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { initAll(); });
|
||||
} else {
|
||||
initAll();
|
||||
}
|
||||
|
||||
return { init: init, initAll: initAll };
|
||||
}());
|
||||
@@ -13,70 +13,17 @@ var SeaDeal = (function () {
|
||||
var _infoOpen = false;
|
||||
var _spinOrigLabel, _fyiOrigLabel;
|
||||
|
||||
// ── Keyword list ──────────────────────────────────────────────────────────
|
||||
|
||||
function _populateList(listEl, items) {
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = (items || []).map(function (k) {
|
||||
return '<li>' + k + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Stage card population ─────────────────────────────────────────────────
|
||||
// ── Stage card population (delegates to shared StageCard module) ──────────
|
||||
|
||||
function _populate(card, isLevity) {
|
||||
var qualifier = isLevity
|
||||
? (card.levity_qualifier || '')
|
||||
: (card.gravity_qualifier || '');
|
||||
var isMajor = card.arcana === 'MAJOR';
|
||||
var title = card.name_title || card.name || '';
|
||||
|
||||
// Corners
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) {
|
||||
el.textContent = card.corner_rank || '';
|
||||
var polarity = isLevity ? 'levity' : 'gravity';
|
||||
StageCard.populateCard(stageCard, card, polarity);
|
||||
StageCard.populateKeywords(statBlock, card.keywords_upright, card.keywords_reversed, {
|
||||
uprightSel: '#id_sea_stat_upright',
|
||||
reversedSel: '#id_sea_stat_reversed',
|
||||
});
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (card.suit_icon) {
|
||||
el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Upright face
|
||||
var nameGroupEl = stageCard.querySelector('.fan-card-name-group');
|
||||
if (nameGroupEl) nameGroupEl.textContent = card.name_group || '';
|
||||
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
|
||||
if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
|
||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Reversal face (same slot-swap logic as sig-select)
|
||||
var reversal = card.reversal || '';
|
||||
if (isMajor) {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ',';
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier;
|
||||
} else if (reversal) {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
} else {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
}
|
||||
|
||||
// Keywords
|
||||
_populateList(overlay.querySelector('#id_sea_stat_upright'), card.keywords_upright);
|
||||
_populateList(overlay.querySelector('#id_sea_stat_reversed'), card.keywords_reversed);
|
||||
|
||||
// FYI data (energies + operations)
|
||||
_infoData = (card.energies || []).map(function (e) {
|
||||
return { type: e.type, effect: e.effect, category: 'energies' };
|
||||
}).concat((card.operations || []).map(function (o) {
|
||||
return { type: o.type, effect: o.effect, category: 'operations' };
|
||||
}));
|
||||
_infoIdx = 0;
|
||||
_infoData = StageCard.buildInfoData(card);
|
||||
_infoIdx = 0;
|
||||
|
||||
// Reset SPIN
|
||||
stageCard.classList.remove('stage-card--reversed');
|
||||
@@ -87,23 +34,7 @@ var SeaDeal = (function () {
|
||||
// ── FYI info panel ────────────────────────────────────────────────────────
|
||||
|
||||
function _renderInfo() {
|
||||
if (!fyiPanel) return;
|
||||
if (_infoData.length === 0) {
|
||||
fyiTitle.textContent = 'Energy';
|
||||
fyiTitle.className = 'sig-info-title sig-info-title--energies';
|
||||
if (fyiType) fyiType.textContent = '';
|
||||
if (fyiEffect) fyiEffect.innerHTML = '<em>No interactions defined.</em>';
|
||||
fyiIndex.textContent = '';
|
||||
return;
|
||||
}
|
||||
var entry = _infoData[_infoIdx];
|
||||
var isEnergies = entry.category === 'energies';
|
||||
fyiTitle.textContent = isEnergies ? 'Energy' : 'Operation';
|
||||
fyiTitle.className = 'sig-info-title sig-info-title--' + entry.category;
|
||||
if (fyiType) fyiType.textContent = entry.type || '';
|
||||
if (fyiEffect) fyiEffect.innerHTML = entry.effect || '';
|
||||
fyiIndex.textContent = _infoData.length > 1
|
||||
? (_infoIdx + 1) + ' / ' + _infoData.length : '';
|
||||
StageCard.renderFyi(fyiPanel, _infoData, _infoIdx);
|
||||
}
|
||||
|
||||
function _openInfo() {
|
||||
@@ -112,7 +43,7 @@ var SeaDeal = (function () {
|
||||
if (fyiPanel) fyiPanel.style.display = 'flex';
|
||||
if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; }
|
||||
if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; }
|
||||
stage.classList.add('sea-info-open');
|
||||
statBlock.classList.add('fyi-open');
|
||||
}
|
||||
|
||||
function _closeInfo() {
|
||||
@@ -120,7 +51,7 @@ var SeaDeal = (function () {
|
||||
if (fyiPanel) fyiPanel.style.display = 'none';
|
||||
if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel || 'SPIN'; }
|
||||
if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel || 'FYI'; }
|
||||
if (stage) stage.classList.remove('sea-info-open');
|
||||
if (statBlock) statBlock.classList.remove('fyi-open');
|
||||
}
|
||||
|
||||
// ── Slot fill ─────────────────────────────────────────────────────────────
|
||||
@@ -187,8 +118,8 @@ var SeaDeal = (function () {
|
||||
|
||||
_userPolarity = overlay.dataset.seaUserPolarity || 'levity';
|
||||
|
||||
spinBtn = statBlock.querySelector('.sea-spin-btn');
|
||||
fyiBtn = statBlock.querySelector('.sea-fyi-btn');
|
||||
spinBtn = statBlock.querySelector('.spin-btn');
|
||||
fyiBtn = statBlock.querySelector('.fyi-btn');
|
||||
bdrop = stage.querySelector('.sea-stage-backdrop');
|
||||
|
||||
fyiPanel = overlay.querySelector('#id_sea_fyi_panel');
|
||||
@@ -196,8 +127,8 @@ var SeaDeal = (function () {
|
||||
fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type');
|
||||
fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect');
|
||||
fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index');
|
||||
fyiPrev = statBlock.querySelector('.sea-fyi-prev');
|
||||
fyiNext = statBlock.querySelector('.sea-fyi-next');
|
||||
fyiPrev = statBlock.querySelector('.fyi-prev');
|
||||
fyiNext = statBlock.querySelector('.fyi-next');
|
||||
|
||||
_spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN';
|
||||
_fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI';
|
||||
@@ -225,7 +156,7 @@ var SeaDeal = (function () {
|
||||
// Clicking the FYI panel itself dismisses it (same as sig-select caution)
|
||||
if (fyiPanel) {
|
||||
fyiPanel.addEventListener('click', function (e) {
|
||||
if (!e.target.closest('.sea-fyi-prev') && !e.target.closest('.sea-fyi-next')) {
|
||||
if (!e.target.closest('.fyi-prev') && !e.target.closest('.fyi-next')) {
|
||||
_closeInfo();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -36,63 +36,32 @@ var SigSelect = (function () {
|
||||
|
||||
// ── Stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _populateKeywordList(listEl, csv) {
|
||||
var keywords = csv ? csv.split(',').filter(Boolean) : [];
|
||||
listEl.innerHTML = keywords.map(function (k) {
|
||||
return '<li>' + k.trim() + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
// _populateKeywordList removed — sig now delegates to StageCard.populateKeywords
|
||||
|
||||
// ── Caution tooltip ───────────────────────────────────────────────────
|
||||
|
||||
function _renderCaution() {
|
||||
if (_cautionData.length === 0) {
|
||||
cautionTitle.textContent = 'Energy';
|
||||
cautionTitle.className = 'sig-info-title sig-info-title--energies';
|
||||
if (cautionTypeEl) cautionTypeEl.textContent = '';
|
||||
cautionEffect.innerHTML = '<em>No interactions defined.</em>';
|
||||
cautionPrev.disabled = true;
|
||||
cautionNext.disabled = true;
|
||||
cautionIndexEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var entry = _cautionData[_cautionIdx];
|
||||
var isEnergies = entry.category === 'energies';
|
||||
cautionTitle.textContent = isEnergies ? 'Energy' : 'Operation';
|
||||
cautionTitle.className = 'sig-info-title sig-info-title--' + entry.category;
|
||||
if (cautionTypeEl) cautionTypeEl.textContent = entry.type || '';
|
||||
cautionEffect.innerHTML = entry.effect || '';
|
||||
cautionPrev.disabled = (_cautionData.length <= 1);
|
||||
cautionNext.disabled = (_cautionData.length <= 1);
|
||||
cautionIndexEl.textContent = _cautionData.length > 1
|
||||
? (_cautionIdx + 1) + ' / ' + _cautionData.length
|
||||
: '';
|
||||
StageCard.renderFyi(stage.querySelector('.sig-info'), _cautionData, _cautionIdx);
|
||||
// Sig disables PRV/NXT when there's only one entry (sea/fan don't bother).
|
||||
if (cautionPrev) cautionPrev.disabled = (_cautionData.length <= 1);
|
||||
if (cautionNext) cautionNext.disabled = (_cautionData.length <= 1);
|
||||
}
|
||||
|
||||
function _openCaution() {
|
||||
if (!_focusedCardEl) return;
|
||||
try {
|
||||
var energies = JSON.parse(_focusedCardEl.dataset.energies || '[]');
|
||||
var operations = JSON.parse(_focusedCardEl.dataset.operations || '[]');
|
||||
_cautionData = energies.map(function (e) {
|
||||
return { type: e.type, effect: e.effect, category: 'energies' };
|
||||
}).concat(operations.map(function (o) {
|
||||
return { type: o.type, effect: o.effect, category: 'operations' };
|
||||
}));
|
||||
} catch (e) {
|
||||
_cautionData = [];
|
||||
}
|
||||
var card = StageCard.fromDataset(_focusedCardEl);
|
||||
_cautionData = StageCard.buildInfoData(card);
|
||||
_cautionIdx = 0;
|
||||
_renderCaution();
|
||||
_flipBtn.classList.add('btn-disabled');
|
||||
_cautionBtn.classList.add('btn-disabled');
|
||||
_flipBtn.textContent = '\u00D7';
|
||||
_cautionBtn.textContent = '\u00D7';
|
||||
stage.classList.add('sig-info-open');
|
||||
statBlock.classList.add('fyi-open');
|
||||
}
|
||||
|
||||
function _closeCaution() {
|
||||
stage.classList.remove('sig-info-open');
|
||||
if (statBlock) statBlock.classList.remove('fyi-open');
|
||||
if (_flipBtn) {
|
||||
_flipBtn.classList.remove('btn-disabled');
|
||||
_cautionBtn.classList.remove('btn-disabled');
|
||||
@@ -112,65 +81,19 @@ var SigSelect = (function () {
|
||||
}
|
||||
_focusedCardEl = cardEl;
|
||||
|
||||
var rank = cardEl.dataset.cornerRank || '';
|
||||
var icon = cardEl.dataset.suitIcon || '';
|
||||
var group = cardEl.dataset.nameGroup || '';
|
||||
var title = cardEl.dataset.nameTitle || '';
|
||||
var arcana= cardEl.dataset.arcana || '';
|
||||
var corr = cardEl.dataset.correspondence || '';
|
||||
var card = StageCard.fromDataset(cardEl);
|
||||
StageCard.populateCard(stageCard, card, userPolarity);
|
||||
// Sig hides the correspondence in the stage card (shown in game-kit only)
|
||||
var corrEl = stageCard.querySelector('.fan-card-correspondence');
|
||||
if (corrEl) corrEl.textContent = '';
|
||||
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (icon) {
|
||||
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||
|
||||
var qualifier = userPolarity === 'levity'
|
||||
? (cardEl.dataset.levityQualifier || '')
|
||||
: (cardEl.dataset.gravityQualifier || '');
|
||||
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Reversed face.
|
||||
// - Major arcana: polarity qualifier + reversal concept name
|
||||
// - Non-major w. reversal: suit qualifier word replaces polarity qualifier;
|
||||
// card name (title) stays the same — two separate lines
|
||||
// - Non-major w/o reversal: fall back to mirroring the polarity qualifier
|
||||
var reversal = cardEl.dataset.reversal || '';
|
||||
if (isMajor) {
|
||||
// Slots are swapped vs. non-major: spin reverses DOM order visually,
|
||||
// so qualifier-slot (DOM-second) appears first and name-slot (DOM-first) appears second.
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ',';
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier;
|
||||
} else if (reversal) {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
} else {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
}
|
||||
|
||||
// Populate stat block keyword faces and reset to upright
|
||||
// Reset SPIN state, then populate keyword lists
|
||||
statBlock.classList.remove('is-reversed');
|
||||
stageCard.classList.remove('stage-card--reversed');
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_upright'),
|
||||
cardEl.dataset.keywordsUpright
|
||||
);
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_reversed'),
|
||||
cardEl.dataset.keywordsReversed
|
||||
);
|
||||
StageCard.populateKeywords(statBlock, card.keywords_upright, card.keywords_reversed, {
|
||||
uprightSel: '#id_stat_keywords_upright',
|
||||
reversedSel: '#id_stat_keywords_reversed',
|
||||
});
|
||||
|
||||
stageCard.style.display = '';
|
||||
stage.classList.add('sig-stage--active');
|
||||
@@ -639,8 +562,8 @@ var SigSelect = (function () {
|
||||
stageCard = stage.querySelector('.sig-stage-card');
|
||||
statBlock = stage.querySelector('.sig-stat-block');
|
||||
|
||||
_flipBtn = statBlock.querySelector('.sig-flip-btn');
|
||||
_cautionBtn = statBlock.querySelector('.sig-info-btn');
|
||||
_flipBtn = statBlock.querySelector('.spin-btn');
|
||||
_cautionBtn = statBlock.querySelector('.fyi-btn');
|
||||
_flipOrigLabel = _flipBtn.textContent;
|
||||
_cautionOrigLabel = _cautionBtn.textContent;
|
||||
|
||||
@@ -654,8 +577,8 @@ var SigSelect = (function () {
|
||||
cautionEffect = cautionEl.querySelector('.sig-info-effect');
|
||||
cautionTitle = cautionEl.querySelector('.sig-info-title');
|
||||
cautionTypeEl = cautionEl.querySelector('.sig-info-type');
|
||||
cautionPrev = statBlock.querySelector('.sig-info-prev');
|
||||
cautionNext = statBlock.querySelector('.sig-info-next');
|
||||
cautionPrev = statBlock.querySelector('.fyi-prev');
|
||||
cautionNext = statBlock.querySelector('.fyi-next');
|
||||
cautionIndexEl = cautionEl.querySelector('.sig-info-index');
|
||||
|
||||
// Clicking the tooltip (not nav buttons) dismisses it
|
||||
@@ -665,7 +588,7 @@ var SigSelect = (function () {
|
||||
|
||||
_cautionBtn.addEventListener('click', function () {
|
||||
if (_cautionBtn.classList.contains('btn-disabled')) return;
|
||||
stage.classList.contains('sig-info-open') ? _closeCaution() : _openCaution();
|
||||
statBlock.classList.contains('fyi-open') ? _closeCaution() : _openCaution();
|
||||
});
|
||||
cautionPrev.addEventListener('click', function () {
|
||||
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;
|
||||
|
||||
158
src/apps/epic/static/apps/epic/stage-card.js
Normal file
158
src/apps/epic/static/apps/epic/stage-card.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// Shared stage-card helpers used by Sig Select, Sea Select, and the Game Kit fan.
|
||||
//
|
||||
// Each call site handles its own DOM lookup, click wiring, and polarity decisions;
|
||||
// this module owns the duplicated logic for normalizing a card source, painting
|
||||
// the upright/reversal faces, populating the stat-block keyword lists, and
|
||||
// rendering the FYI panel entries.
|
||||
var StageCard = (function () {
|
||||
'use strict';
|
||||
|
||||
function _parseCSV(str) {
|
||||
return (str || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
}
|
||||
function _parseJSON(str) {
|
||||
try { return JSON.parse(str || '[]'); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
// Normalize a `.sig-card` / `.fan-card` element's data-* attrs into the same
|
||||
// shape that sea.js receives via fetch (so all call sites can share populateCard).
|
||||
function fromDataset(el) {
|
||||
return {
|
||||
corner_rank: el.dataset.cornerRank || '',
|
||||
suit_icon: el.dataset.suitIcon || '',
|
||||
name_group: el.dataset.nameGroup || '',
|
||||
name_title: el.dataset.nameTitle || '',
|
||||
arcana: el.dataset.arcana || '',
|
||||
correspondence: el.dataset.correspondence || '',
|
||||
keywords_upright: _parseCSV(el.dataset.keywordsUpright),
|
||||
keywords_reversed: _parseCSV(el.dataset.keywordsReversed),
|
||||
energies: _parseJSON(el.dataset.energies),
|
||||
operations: _parseJSON(el.dataset.operations),
|
||||
levity_qualifier: el.dataset.levityQualifier || '',
|
||||
gravity_qualifier: el.dataset.gravityQualifier || '',
|
||||
reversal_qualifier: el.dataset.reversalQualifier || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Decide whether a card object represents Major Arcana — sig sources from
|
||||
// `data-arcana` (Django's `get_arcana_display`, e.g. "Major Arcana"), sea
|
||||
// from card.arcana (model code, e.g. "MAJOR"). Accept both.
|
||||
function _isMajor(card) {
|
||||
var a = (card.arcana || '').toUpperCase();
|
||||
return a === 'MAJOR' || a === 'MAJOR ARCANA';
|
||||
}
|
||||
|
||||
// Paint the stage-card's upright + reversal faces from a normalized card
|
||||
// object + the active polarity ('levity' | 'gravity'). Reversal-qualifier
|
||||
// falls back to the current polarity's qualifier when blank (6F behavior).
|
||||
function populateCard(stageCard, card, polarity) {
|
||||
if (!stageCard) return;
|
||||
var isLevity = polarity === 'levity';
|
||||
var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || '');
|
||||
var isMajor = _isMajor(card);
|
||||
var title = card.name_title || '';
|
||||
var reversalQualifier = card.reversal_qualifier || '';
|
||||
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) {
|
||||
el.textContent = card.corner_rank || '';
|
||||
});
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (card.suit_icon) {
|
||||
el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
var nameGroupEl = stageCard.querySelector('.fan-card-name-group');
|
||||
if (nameGroupEl) nameGroupEl.textContent = card.name_group || '';
|
||||
|
||||
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
|
||||
if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
|
||||
|
||||
var nameEl = stageCard.querySelector('.fan-card-name');
|
||||
if (nameEl) nameEl.textContent = isMajor ? title + ',' : title;
|
||||
|
||||
var qAbove = stageCard.querySelector('.sig-qualifier-above');
|
||||
if (qAbove) qAbove.textContent = isMajor ? '' : qualifier;
|
||||
var qBelow = stageCard.querySelector('.sig-qualifier-below');
|
||||
if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Reversal face — three cases:
|
||||
// Major: title (with comma) in qualifier slot, qualifier in name slot
|
||||
// Non-major + reversal_qual: reversal_qualifier in qualifier slot, title in name slot
|
||||
// Non-major no reversal_qual: fall back to current polarity's qualifier
|
||||
var rQual = stageCard.querySelector('.fan-card-reversal-qualifier');
|
||||
var rName = stageCard.querySelector('.fan-card-reversal-name');
|
||||
if (rQual && rName) {
|
||||
if (isMajor) {
|
||||
rQual.textContent = title + ',';
|
||||
rName.textContent = qualifier;
|
||||
} else if (reversalQualifier) {
|
||||
rQual.textContent = reversalQualifier;
|
||||
rName.textContent = title;
|
||||
} else {
|
||||
rQual.textContent = qualifier;
|
||||
rName.textContent = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the upright + reversed keyword <ul>s. Accepts either CSS-selector
|
||||
// overrides or the default `.stat-face--upright .stat-keywords` /
|
||||
// `.stat-face--reversed .stat-keywords`.
|
||||
function populateKeywords(statBlock, uprightItems, reversedItems, opts) {
|
||||
if (!statBlock) return;
|
||||
opts = opts || {};
|
||||
var upSel = opts.uprightSel || '.stat-face--upright .stat-keywords';
|
||||
var rvSel = opts.reversedSel || '.stat-face--reversed .stat-keywords';
|
||||
var ul = statBlock.querySelector(upSel);
|
||||
var rl = statBlock.querySelector(rvSel);
|
||||
if (ul) ul.innerHTML = (uprightItems || []).map(function (k) { return '<li>' + k + '</li>'; }).join('');
|
||||
if (rl) rl.innerHTML = (reversedItems || []).map(function (k) { return '<li>' + k + '</li>'; }).join('');
|
||||
}
|
||||
|
||||
// Concatenate energies + operations into a single FYI nav array.
|
||||
function buildInfoData(card) {
|
||||
var data = (card.energies || []).map(function (e) {
|
||||
return { type: e.type, effect: e.effect, category: 'energies' };
|
||||
});
|
||||
return data.concat((card.operations || []).map(function (o) {
|
||||
return { type: o.type, effect: o.effect, category: 'operations' };
|
||||
}));
|
||||
}
|
||||
|
||||
// Paint a single FYI entry into the .sig-info panel. Caller owns idx + data.
|
||||
function renderFyi(infoPanel, data, idx) {
|
||||
if (!infoPanel) return;
|
||||
var title = infoPanel.querySelector('.sig-info-title');
|
||||
var type = infoPanel.querySelector('.sig-info-type');
|
||||
var effect = infoPanel.querySelector('.sig-info-effect');
|
||||
var index = infoPanel.querySelector('.sig-info-index');
|
||||
if (!data || data.length === 0) {
|
||||
if (title) { title.textContent = 'Energy';
|
||||
title.className = 'sig-info-title sig-info-title--energies'; }
|
||||
if (type) type.textContent = '';
|
||||
if (effect) effect.innerHTML = '<em>No interactions defined.</em>';
|
||||
if (index) index.textContent = '';
|
||||
return;
|
||||
}
|
||||
var entry = data[idx];
|
||||
var isEnergies = entry.category === 'energies';
|
||||
if (title) { title.textContent = isEnergies ? 'Energy' : 'Operation';
|
||||
title.className = 'sig-info-title sig-info-title--' + entry.category; }
|
||||
if (type) type.textContent = entry.type || '';
|
||||
if (effect) effect.innerHTML = entry.effect || '';
|
||||
if (index) index.textContent = data.length > 1
|
||||
? (idx + 1) + ' / ' + data.length : '';
|
||||
}
|
||||
|
||||
return {
|
||||
fromDataset: fromDataset,
|
||||
populateCard: populateCard,
|
||||
populateKeywords: populateKeywords,
|
||||
buildInfoData: buildInfoData,
|
||||
renderFyi: renderFyi,
|
||||
};
|
||||
}());
|
||||
@@ -1112,7 +1112,7 @@ class SigSelectRenderingTest(TestCase):
|
||||
def test_sig_stat_block_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-stat-block")
|
||||
self.assertContains(response, "sig-flip-btn")
|
||||
self.assertContains(response, "spin-btn")
|
||||
self.assertContains(response, "stat-face--upright")
|
||||
self.assertContains(response, "stat-face--reversed")
|
||||
|
||||
@@ -1124,11 +1124,11 @@ class SigSelectRenderingTest(TestCase):
|
||||
def test_sig_info_panel_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-info")
|
||||
self.assertContains(response, "sig-info-btn")
|
||||
self.assertContains(response, "fyi-btn")
|
||||
self.assertContains(response, "sig-info-effect")
|
||||
self.assertContains(response, "sig-info-index")
|
||||
self.assertContains(response, "sig-info-prev")
|
||||
self.assertContains(response, "sig-info-next")
|
||||
self.assertContains(response, "fyi-prev")
|
||||
self.assertContains(response, "fyi-next")
|
||||
|
||||
|
||||
class SelectSigCardViewTest(TestCase):
|
||||
|
||||
@@ -1145,7 +1145,7 @@ def sea_deck(request, room_id):
|
||||
'name_title': c.name_title,
|
||||
'levity_qualifier': c.levity_qualifier,
|
||||
'gravity_qualifier': c.gravity_qualifier,
|
||||
'reversal': c.reversal,
|
||||
'reversal_qualifier': c.reversal_qualifier,
|
||||
'keywords_upright': c.keywords_upright,
|
||||
'keywords_reversed': c.keywords_reversed,
|
||||
'energies': c.energies,
|
||||
|
||||
@@ -1,93 +1,226 @@
|
||||
function initGameKitPage() {
|
||||
const dialog = document.getElementById('id_tarot_fan_dialog');
|
||||
if (!dialog) return;
|
||||
var GameKit = (function () {
|
||||
'use strict';
|
||||
|
||||
const fanContent = document.getElementById('id_fan_content');
|
||||
const prevBtn = document.getElementById('id_fan_prev');
|
||||
const nextBtn = document.getElementById('id_fan_next');
|
||||
var dialog, fanContent, prevBtn, nextBtn, fanWrap, flipBtn;
|
||||
var stageBlock, spinBtn, fyiBtn, fyiPanel;
|
||||
var fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
|
||||
var statUpright, statReversed;
|
||||
var _polarity = 'levity'; // FLIP toggles 'levity' ↔ 'gravity'
|
||||
|
||||
let currentDeckId = null;
|
||||
let currentIndex = 0;
|
||||
let cards = [];
|
||||
var currentDeckId = null;
|
||||
var currentIndex = 0;
|
||||
var cards = [];
|
||||
var _revealTimer = null;
|
||||
var REVEAL_DELAY_MS = 500;
|
||||
|
||||
function storageKey(deckId) {
|
||||
return 'tarot-fan-' + deckId;
|
||||
}
|
||||
var _infoData = [];
|
||||
var _infoIdx = 0;
|
||||
var _infoOpen = false;
|
||||
var _spinOrigLabel = 'SPIN';
|
||||
var _fyiOrigLabel = 'FYI';
|
||||
|
||||
// ── Storage ────────────────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
var saved = sessionStorage.getItem(storageKey(deckId));
|
||||
return saved !== null ? parseInt(saved, 10) : 0;
|
||||
}
|
||||
|
||||
// ── Carousel transforms ───────────────────────────────────────────────────
|
||||
|
||||
function _carouselStep() {
|
||||
// Pull the per-breakpoint step length from CSS so mobile @media rules
|
||||
// can shrink the carousel without code changes.
|
||||
if (!fanWrap) return 200;
|
||||
var raw = getComputedStyle(fanWrap).getPropertyValue('--fan-carousel-step').trim();
|
||||
var n = parseFloat(raw);
|
||||
return isFinite(n) && n > 0 ? n : 200;
|
||||
}
|
||||
|
||||
function cardTransform(offset) {
|
||||
const abs = Math.abs(offset);
|
||||
var abs = Math.abs(offset);
|
||||
var step = _carouselStep();
|
||||
return {
|
||||
transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
|
||||
transform: 'translateX(' + (offset * step) + '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;
|
||||
var total = cards.length;
|
||||
if (!total) return;
|
||||
cards.forEach(function(card, i) {
|
||||
let offset = i - currentIndex;
|
||||
cards.forEach(function (card, i) {
|
||||
var offset = i - currentIndex;
|
||||
if (offset > total / 2) offset -= total;
|
||||
if (offset < -total / 2) offset += total;
|
||||
|
||||
const abs = Math.abs(offset);
|
||||
var 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;
|
||||
var t = cardTransform(offset);
|
||||
// Active card may also be reversed — append rotate(180deg) to
|
||||
// the carousel transform without disturbing the layout values.
|
||||
var spin = (offset === 0 && card.classList.contains('stage-card--reversed'))
|
||||
? ' rotate(180deg)' : '';
|
||||
card.style.transform = t.transform + spin;
|
||||
card.style.opacity = t.opacity;
|
||||
card.style.zIndex = t.zIndex;
|
||||
}
|
||||
});
|
||||
_populateStage();
|
||||
_scheduleReveal();
|
||||
}
|
||||
|
||||
// ── Stage block: idle reveal / careen-out ─────────────────────────────────
|
||||
|
||||
function _scheduleReveal() {
|
||||
if (!stageBlock) return;
|
||||
stageBlock.classList.remove('is-revealed');
|
||||
if (_revealTimer) clearTimeout(_revealTimer);
|
||||
_revealTimer = setTimeout(function () {
|
||||
stageBlock.classList.add('is-revealed');
|
||||
}, REVEAL_DELAY_MS);
|
||||
}
|
||||
|
||||
function _hideStageImmediate() {
|
||||
if (!stageBlock) return;
|
||||
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
|
||||
stageBlock.classList.remove('is-revealed');
|
||||
_closeFyi();
|
||||
}
|
||||
|
||||
// ── Stage population (delegates to shared StageCard module) ──────────────
|
||||
|
||||
function _populateStage() {
|
||||
if (!stageBlock || !cards.length) return;
|
||||
var cardEl = cards[currentIndex];
|
||||
if (!cardEl) return;
|
||||
|
||||
var card = StageCard.fromDataset(cardEl);
|
||||
// Repaint the focused fan-card's faces in the active polarity (so FLIP
|
||||
// and per-polarity qualifier rendering stay consistent with the data).
|
||||
StageCard.populateCard(cardEl, card, _polarity);
|
||||
cardEl.dataset.polarity = _polarity;
|
||||
|
||||
StageCard.populateKeywords(stageBlock, card.keywords_upright, card.keywords_reversed, {
|
||||
uprightSel: '#id_fan_stat_upright',
|
||||
reversedSel: '#id_fan_stat_reversed',
|
||||
});
|
||||
_infoData = StageCard.buildInfoData(card);
|
||||
_infoIdx = 0;
|
||||
|
||||
// Reset SPIN state on every card change — clears rotation on all cards
|
||||
// so a previously-reversed card returns to upright when it leaves focus.
|
||||
stageBlock.classList.remove('is-reversed');
|
||||
cards.forEach(function (c) { c.classList.remove('stage-card--reversed'); });
|
||||
_closeFyi();
|
||||
}
|
||||
|
||||
// ── FLIP — toggle polarity with perspective horizontal flip animation ────
|
||||
// Retains SPIN state across the flip (the .stage-card--reversed class on
|
||||
// the card is left untouched).
|
||||
|
||||
function _flipActive() {
|
||||
var active = cards[currentIndex];
|
||||
if (!active) return;
|
||||
if (active.dataset.flipping) return; // mid-flip
|
||||
active.dataset.flipping = '1';
|
||||
|
||||
// Build the resting transform (carousel offset 0 + optional SPIN rotate(180))
|
||||
// and the edge-on midpoint by adding a rotateY layer at the end. Keeping
|
||||
// the carousel + spin intact means the card actually rotates in place
|
||||
// around its centre rather than just the inner face.
|
||||
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
|
||||
var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin;
|
||||
var mid = 'translateX(0px) rotateY(0deg) scale(1)' + spin + ' rotateY(90deg)';
|
||||
|
||||
active.animate([
|
||||
{ transform: rest },
|
||||
{ transform: mid, offset: 0.5 },
|
||||
{ transform: rest },
|
||||
], { duration: 500, easing: 'ease' });
|
||||
|
||||
_polarity = (_polarity === 'levity') ? 'gravity' : 'levity';
|
||||
// Swap polarity content + colour at the edge-on midpoint, so the user
|
||||
// sees the new face from the start of the second half-rotation.
|
||||
setTimeout(function () {
|
||||
var card = StageCard.fromDataset(active);
|
||||
StageCard.populateCard(active, card, _polarity);
|
||||
active.dataset.polarity = _polarity;
|
||||
}, 250);
|
||||
// Clear the in-flight flag at animation end. Using setTimeout (not
|
||||
// anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests.
|
||||
setTimeout(function () { delete active.dataset.flipping; }, 500);
|
||||
}
|
||||
|
||||
// ── FYI panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _renderFyi() {
|
||||
StageCard.renderFyi(fyiPanel, _infoData, _infoIdx);
|
||||
}
|
||||
|
||||
function _openFyi() {
|
||||
_infoOpen = true;
|
||||
_renderFyi();
|
||||
if (fyiPanel) fyiPanel.style.display = 'flex';
|
||||
if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; }
|
||||
if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; }
|
||||
if (stageBlock) stageBlock.classList.add('fyi-open');
|
||||
}
|
||||
|
||||
function _closeFyi() {
|
||||
_infoOpen = false;
|
||||
if (fyiPanel) fyiPanel.style.display = 'none';
|
||||
if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel; }
|
||||
if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel; }
|
||||
if (stageBlock) stageBlock.classList.remove('fyi-open');
|
||||
}
|
||||
|
||||
// ── Open / close the dialog ───────────────────────────────────────────────
|
||||
|
||||
function openFan(deckId) {
|
||||
currentDeckId = deckId;
|
||||
currentIndex = restorePosition(deckId);
|
||||
|
||||
fetch('/gameboard/game-kit/deck/' + deckId + '/')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
.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;
|
||||
cards.forEach(function(c) {
|
||||
cards.forEach(function (c) {
|
||||
c.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
|
||||
});
|
||||
updateFan();
|
||||
dialog.showModal();
|
||||
if (dialog && typeof dialog.showModal === 'function') dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function closeFan() {
|
||||
savePosition();
|
||||
dialog.close();
|
||||
_hideStageImmediate();
|
||||
if (dialog && typeof dialog.close === 'function') dialog.close();
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function navigate(delta) {
|
||||
if (!cards.length) return;
|
||||
currentIndex = (currentIndex + delta + cards.length) % cards.length;
|
||||
savePosition();
|
||||
_hideStageImmediate();
|
||||
updateFan();
|
||||
}
|
||||
|
||||
// Step through multiple cards one at a time so intermediate cards are visible
|
||||
var _navTimer = null;
|
||||
function navigateAnimated(steps) {
|
||||
if (!cards.length || steps === 0) return;
|
||||
@@ -102,64 +235,182 @@ function initGameKitPage() {
|
||||
tick();
|
||||
}
|
||||
|
||||
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
|
||||
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
|
||||
dialog.addEventListener('click', function(e) {
|
||||
if (e.target === dialog || e.target === fanWrap) closeFan();
|
||||
});
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Arrow key navigation
|
||||
dialog.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'ArrowRight') navigate(1);
|
||||
if (e.key === 'ArrowLeft') navigate(-1);
|
||||
});
|
||||
function init() {
|
||||
dialog = document.getElementById('id_tarot_fan_dialog');
|
||||
if (!dialog) return;
|
||||
|
||||
// Mousewheel navigation — accumulate delta, cap at 3 cards per event so fast
|
||||
// spins don't overshoot; CSS transitions handle the visual smoothness.
|
||||
var wheelAccum = 0;
|
||||
var wheelDecayTimer = null;
|
||||
var WHEEL_STEP = 150;
|
||||
dialog.addEventListener('wheel', function(e) {
|
||||
e.preventDefault();
|
||||
clearTimeout(wheelDecayTimer);
|
||||
wheelAccum += e.deltaY;
|
||||
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
|
||||
if (steps !== 0) {
|
||||
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
|
||||
wheelAccum -= steps * WHEEL_STEP;
|
||||
navigate(steps);
|
||||
fanContent = document.getElementById('id_fan_content');
|
||||
prevBtn = document.getElementById('id_fan_prev');
|
||||
nextBtn = document.getElementById('id_fan_next');
|
||||
flipBtn = document.getElementById('id_fan_flip');
|
||||
fanWrap = dialog.querySelector('.tarot-fan-wrap');
|
||||
|
||||
stageBlock = document.getElementById('id_fan_stage_block');
|
||||
if (stageBlock) {
|
||||
spinBtn = stageBlock.querySelector('.spin-btn');
|
||||
fyiBtn = stageBlock.querySelector('.fyi-btn');
|
||||
fyiPanel = stageBlock.querySelector('#id_fan_fyi_panel');
|
||||
fyiTitle = fyiPanel && fyiPanel.querySelector('.sig-info-title');
|
||||
fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type');
|
||||
fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect');
|
||||
fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index');
|
||||
fyiPrev = stageBlock.querySelector('.fyi-prev');
|
||||
fyiNext = stageBlock.querySelector('.fyi-next');
|
||||
statUpright = stageBlock.querySelector('#id_fan_stat_upright');
|
||||
statReversed = stageBlock.querySelector('#id_fan_stat_reversed');
|
||||
_spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN';
|
||||
_fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI';
|
||||
}
|
||||
wheelDecayTimer = setTimeout(function() { wheelAccum = 0; }, 200);
|
||||
}, { passive: false });
|
||||
|
||||
// Touch/swipe navigation — uses navigateAnimated so intermediate cards are visible
|
||||
var touchStartX = 0;
|
||||
var touchStartY = 0;
|
||||
var touchStartTime = 0;
|
||||
dialog.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
}, { passive: true });
|
||||
dialog.addEventListener('touchend', function(e) {
|
||||
var dx = e.changedTouches[0].clientX - touchStartX;
|
||||
var dy = e.changedTouches[0].clientY - touchStartY;
|
||||
if (Math.abs(dy) > Math.abs(dx)) return; // vertical swipe — ignore
|
||||
if (Math.abs(dx) < 60) return; // dead zone — raise to 40–60 for more deliberate swipe required
|
||||
var elapsed = Math.max(1, Date.now() - touchStartTime);
|
||||
var velocity = Math.abs(dx) / elapsed; // px/ms
|
||||
var steps = velocity > 0.8 // flick threshold — raise (e.g. 0.6) so more swipes use drag formula
|
||||
? Math.max(1, Math.round(velocity * 4)) // flick multiplier — lower (e.g. 4–5) to reduce cards per fast flick
|
||||
: Math.round(Math.abs(dx) / 150); // slow-drag divisor — raise (e.g. 120–150) for fewer cards per short drag
|
||||
navigateAnimated(dx < 0 ? steps : -steps);
|
||||
}, { passive: true });
|
||||
// Backdrop click closes
|
||||
if (dialog && fanWrap) {
|
||||
dialog.addEventListener('click', function (e) {
|
||||
if (e.target === dialog || e.target === fanWrap) closeFan();
|
||||
});
|
||||
}
|
||||
|
||||
prevBtn.addEventListener('click', function() { navigate(-1); });
|
||||
nextBtn.addEventListener('click', function() { navigate(1); });
|
||||
// Keyboard nav
|
||||
if (dialog) {
|
||||
dialog.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowRight') navigate(1);
|
||||
if (e.key === 'ArrowLeft') navigate(-1);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) {
|
||||
card.addEventListener('click', function() { openFan(card.dataset.deckId); });
|
||||
});
|
||||
}
|
||||
// Wheel nav
|
||||
var wheelAccum = 0;
|
||||
var wheelDecayTimer = null;
|
||||
var WHEEL_STEP = 150;
|
||||
if (dialog) {
|
||||
dialog.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
clearTimeout(wheelDecayTimer);
|
||||
wheelAccum += e.deltaY;
|
||||
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
|
||||
if (steps !== 0) {
|
||||
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
|
||||
wheelAccum -= steps * WHEEL_STEP;
|
||||
navigate(steps);
|
||||
}
|
||||
wheelDecayTimer = setTimeout(function () { wheelAccum = 0; }, 200);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initGameKitPage);
|
||||
// Touch nav
|
||||
var touchStartX = 0, touchStartY = 0, touchStartTime = 0;
|
||||
if (dialog) {
|
||||
dialog.addEventListener('touchstart', function (e) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
}, { passive: true });
|
||||
dialog.addEventListener('touchend', function (e) {
|
||||
var dx = e.changedTouches[0].clientX - touchStartX;
|
||||
var dy = e.changedTouches[0].clientY - touchStartY;
|
||||
if (Math.abs(dy) > Math.abs(dx)) return;
|
||||
if (Math.abs(dx) < 60) return;
|
||||
var elapsed = Math.max(1, Date.now() - touchStartTime);
|
||||
var velocity = Math.abs(dx) / elapsed;
|
||||
var steps = velocity > 0.8
|
||||
? Math.max(1, Math.round(velocity * 4))
|
||||
: Math.round(Math.abs(dx) / 150);
|
||||
navigateAnimated(dx < 0 ? steps : -steps);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
if (prevBtn) prevBtn.addEventListener('click', function () { navigate(-1); });
|
||||
if (nextBtn) nextBtn.addEventListener('click', function () { navigate(1); });
|
||||
if (flipBtn) flipBtn.addEventListener('click', function (e) {
|
||||
// Without this the click bubbles to the dialog's backdrop-close
|
||||
// handler and shuts the modal — FLIP is a primary action, not a
|
||||
// dismiss.
|
||||
e.stopPropagation();
|
||||
_flipActive();
|
||||
});
|
||||
|
||||
// SPIN — toggle stat block face AND rotate the active fan-card 180°.
|
||||
// Reapply the active card's transform directly so the rotation animates
|
||||
// off the existing carousel transform without going through updateFan
|
||||
// (which would reset .stage-card--reversed via _populateStage).
|
||||
if (spinBtn) {
|
||||
spinBtn.addEventListener('click', function () {
|
||||
if (spinBtn.classList.contains('btn-disabled')) return;
|
||||
stageBlock.classList.toggle('is-reversed');
|
||||
var active = cards[currentIndex];
|
||||
if (!active) return;
|
||||
active.classList.toggle('stage-card--reversed');
|
||||
var t = cardTransform(0);
|
||||
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
|
||||
active.style.transform = t.transform + spin;
|
||||
});
|
||||
}
|
||||
|
||||
// FYI
|
||||
if (fyiBtn) {
|
||||
fyiBtn.addEventListener('click', function () {
|
||||
if (fyiBtn.classList.contains('btn-disabled')) return;
|
||||
_infoOpen ? _closeFyi() : _openFyi();
|
||||
});
|
||||
}
|
||||
|
||||
if (fyiPanel) {
|
||||
fyiPanel.addEventListener('click', function (e) {
|
||||
if (!e.target.closest('.fyi-prev') && !e.target.closest('.fyi-next')) {
|
||||
_closeFyi();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (fyiPrev) {
|
||||
fyiPrev.addEventListener('click', function () {
|
||||
if (!_infoData.length) return;
|
||||
_infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length;
|
||||
_renderFyi();
|
||||
});
|
||||
}
|
||||
if (fyiNext) {
|
||||
fyiNext.addEventListener('click', function () {
|
||||
if (!_infoData.length) return;
|
||||
_infoIdx = (_infoIdx + 1) % _infoData.length;
|
||||
_renderFyi();
|
||||
});
|
||||
}
|
||||
|
||||
// Deck-card click → openFan
|
||||
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function (card) {
|
||||
card.addEventListener('click', function () { openFan(card.dataset.deckId); });
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
return {
|
||||
openFan: openFan,
|
||||
closeFan: closeFan,
|
||||
navigate: navigate,
|
||||
reinit: init,
|
||||
_testInit: function () {
|
||||
dialog = null; fanContent = null; prevBtn = null; nextBtn = null; fanWrap = null;
|
||||
flipBtn = null;
|
||||
stageBlock = null; spinBtn = null; fyiBtn = null; fyiPanel = null;
|
||||
fyiTitle = null; fyiType = null; fyiEffect = null; fyiIndex = null;
|
||||
fyiPrev = null; fyiNext = null; statUpright = null; statReversed = null;
|
||||
currentDeckId = null; currentIndex = 0; cards = [];
|
||||
_revealTimer = null; _infoData = []; _infoIdx = 0; _infoOpen = false;
|
||||
_polarity = 'levity';
|
||||
init();
|
||||
},
|
||||
// Test seam: skip fetch by reading already-rendered #id_fan_content
|
||||
_testOpen: function () {
|
||||
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
|
||||
currentIndex = 0;
|
||||
updateFan();
|
||||
},
|
||||
_testNavigate: navigate,
|
||||
};
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user