Compare commits

..

2 Commits

6 changed files with 186 additions and 10 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-01 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_tableseat_significator_fk'),
]
operations = [
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,70 @@
"""
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 15)
in the Earthman deck.
0: "The Schiz""The Nomad"
1: "Pope 1: Chancellor""Pope 1: The Schizo"
2: "Pope 2: President""Pope 2: The Despot"
3: "Pope 3: Tsar""Pope 3: The Capitalist"
4: "Pope 4: Chairman""Pope 4: The Fascist"
5: "Pope 5: Emperor""Pope 5: The War Machine"
"""
from django.db import migrations
NEW_NAMES = {
0: ("The Nomad", "the-nomad"),
1: ("Pope 1: The Schizo", "pope-1-the-schizo"),
2: ("Pope 2: The Despot", "pope-2-the-despot"),
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
5: ("Pope 5: The War Machine","pope-5-the-war-machine"),
}
OLD_NAMES = {
0: ("The Schiz", "the-schiz"),
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
2: ("Pope 2: President", "pope-2-president"),
3: ("Pope 3: Tsar", "pope-3-tsar"),
4: ("Pope 4: Chairman", "pope-4-chairman"),
5: ("Pope 5: Emperor", "pope-5-emperor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in NEW_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in OLD_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_alter_tarotcard_suit"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -213,13 +213,11 @@ class TarotCard(models.Model):
CUPS = "CUPS"
SWORDS = "SWORDS"
PENTACLES = "PENTACLES" # Fiorentine 4th suit
COINS = "COINS" # Earthman 4th suit (Ossum / Stone)
SUIT_CHOICES = [
(WANDS, "Wands"),
(CUPS, "Cups"),
(SWORDS, "Swords"),
(PENTACLES, "Pentacles"),
(COINS, "Coins"),
]
deck_variant = models.ForeignKey(
@@ -282,7 +280,6 @@ class TarotCard(models.Model):
self.WANDS: 'fa-wand-sparkles',
self.CUPS: 'fa-trophy',
self.SWORDS: 'fa-gun',
self.COINS: 'fa-star',
self.PENTACLES: 'fa-star',
}.get(self.suit, '')

View File

@@ -67,6 +67,9 @@ function initGameKitPage() {
fanContent.innerHTML = html;
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
if (currentIndex >= cards.length) currentIndex = 0;
cards.forEach(function(c) {
c.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
});
updateFan();
dialog.showModal();
});
@@ -84,6 +87,21 @@ function initGameKitPage() {
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;
clearTimeout(_navTimer);
var sign = steps > 0 ? 1 : -1;
var remaining = Math.abs(steps);
function tick() {
navigate(sign);
remaining--;
if (remaining > 0) _navTimer = setTimeout(tick, 60);
}
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) {
@@ -96,16 +114,46 @@ function initGameKitPage() {
if (e.key === 'ArrowLeft') navigate(-1);
});
// Mousewheel navigation — throttled so each detent advances one card
var lastWheel = 0;
// 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();
var now = Date.now();
if (now - lastWheel < 150) return;
lastWheel = now;
navigate(e.deltaY > 0 ? 1 : -1);
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 });
// 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 4060 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. 45) to reduce cards per fast flick
: Math.round(Math.abs(dx) / 150); // slow-drag divisor — raise (e.g. 120150) for fewer cards per short drag
navigateAnimated(dx < 0 ? steps : -steps);
}, { passive: true });
prevBtn.addEventListener('click', function() { navigate(-1); });
nextBtn.addEventListener('click', function() { navigate(1); });

View File

@@ -227,6 +227,14 @@
align-items: center;
justify-content: center;
perspective: 900px;
button {
box-shadow: none;
&:hover, &.active {
box-shadow: none;
}
}
}
.tarot-fan {

View File

@@ -62,6 +62,10 @@ html:has(.gate-backdrop) {
pointer-events: none;
}
.launch-game-btn {
margin-top: 1rem;
}
.gate-modal {
display: flex;
flex-direction: column;
@@ -246,6 +250,11 @@ html:has(.gate-backdrop) {
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
@media (max-width: 700px) {
// Floor the gatekeeper modal below the position-strip circles (~1.5rem top + 3rem height)
.gate-overlay {
padding-top: 5.5rem;
}
.gate-modal {
padding: 1.25rem 1.5rem;
@@ -298,7 +307,7 @@ $pos-d-y: round($pos-d * 0.866); // 95px
// absolute children share the root stacking context with the fixed overlays.
.position-strip {
position: absolute;
top: 0.5rem;
top: 1.5rem;
left: 0;
right: 0;
z-index: 130;
@@ -675,6 +684,32 @@ $card-h: 120px;
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) and (max-width: 1440px) {
// Reflow position strip into a vertical column along the left edge,
// reversed so 6 is at top, 1 at bottom, below the GAMEROOM title.
.position-strip {
flex-direction: column-reverse;
top: 3rem;
left: 0.5rem;
right: auto;
gap: round($gate-gap * 0.4);
}
// Shallow landscape (phones): wrap into two columns — left: 6,5,4 / right: 3,2,1
// Columns grow rightward (wrap, not wrap-reverse) so overflow: hidden doesn't clip.
// order: -1 on slots 46 pulls them to the front of the flex sequence; combined
// with column-reverse they land in the left column reading 6,5,4 top-to-bottom.
@media (max-height: 550px) {
.position-strip {
flex-wrap: wrap;
// cap height to exactly 3 circles so the 4th wraps to a new column
max-height: #{3 * round($gate-node * 0.75) + 2 * round($gate-gap * 0.4)};
.gate-slot[data-slot="4"],
.gate-slot[data-slot="5"],
.gate-slot[data-slot="6"] { order: -1; }
}
}
.gate-modal {
padding: 0.6rem 1.25rem;