This commit is contained in:
Ross
2025-12-05 10:46:16 +00:00
parent be1fea5fd4
commit bf9e90d754
13 changed files with 401 additions and 27 deletions
+6 -5
View File
@@ -7,7 +7,7 @@ class DinosaurForm(forms.ModelForm):
model = Dinosaur
fields = [
'name', 'era', 'diet', 'description', 'image',
'speed', 'weight', 'intelligence', 'height',
'background', 'speed', 'weight', 'intelligence', 'height',
'facts',
]
widgets = {
@@ -16,10 +16,11 @@ class DinosaurForm(forms.ModelForm):
'diet': forms.Select(attrs={'class': 'input'}),
'description': forms.Textarea(attrs={'class': 'textarea', 'rows': 3}),
'image': forms.ClearableFileInput(attrs={'class': 'file-input'}),
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100}),
'background': forms.Select(attrs={'class': 'input'}),
'speed': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.SPEED_MAX_KMH), 'placeholder': 'km/h'}),
'weight': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.WEIGHT_MAX_KG), 'placeholder': 'kg'}),
'intelligence': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': 100, 'placeholder': '0-100 score'}),
'height': forms.NumberInput(attrs={'class': 'input', 'min': 0, 'max': int(Dinosaur.HEIGHT_MAX_M), 'step': '0.1', 'placeholder': 'metres'}),
}
# Add facts field widget
@@ -0,0 +1,61 @@
from django.db import migrations, models
def forwards(apps, schema_editor):
Dinosaur = apps.get_model('cards', 'Dinosaur')
# Constants must match those used in models at conversion time
SPEED_MAX_KMH = 60.0
WEIGHT_MAX_KG = 15000.0
HEIGHT_MAX_M = 12.0
for obj in Dinosaur.objects.all():
try:
# previous values were 0-100 percentages
old_speed = getattr(obj, 'speed', None)
old_weight = getattr(obj, 'weight', None)
old_height = getattr(obj, 'height', None)
if old_speed is not None:
new_speed = int(round(float(old_speed) / 100.0 * SPEED_MAX_KMH))
obj.speed = new_speed
if old_weight is not None:
new_weight = int(round(float(old_weight) / 100.0 * WEIGHT_MAX_KG))
obj.weight = new_weight
if old_height is not None:
new_height = round(float(old_height) / 100.0 * HEIGHT_MAX_M, 1)
# assign as string to avoid Decimal conversion issues
obj.height = new_height
obj.save(update_fields=['speed', 'weight', 'height'])
except Exception:
# skip errors during migration for safety; admins can fix manually
continue
def reverse(apps, schema_editor):
# Not reversible automatically
pass
class Migration(migrations.Migration):
dependencies = [
('cards', '0003_dinosaur_facts'),
]
operations = [
migrations.AlterField(
model_name='dinosaur',
name='speed',
field=models.PositiveIntegerField(default=30, help_text='Speed in km/h'),
),
migrations.AlterField(
model_name='dinosaur',
name='weight',
field=models.PositiveIntegerField(default=1000, help_text='Weight in kg'),
),
migrations.AlterField(
model_name='dinosaur',
name='height',
field=models.DecimalField(default=1.5, max_digits=5, decimal_places=1, help_text='Height in metres'),
),
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0004_convert_stats_to_units'),
]
operations = [
migrations.AddField(
model_name='dinosaur',
name='background',
field=models.ForeignKey(blank=True, help_text='Optional per-card background image', null=True, on_delete=models.SET_NULL, related_name='dinosaurs', to='cards.backgroundimage'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-05 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cards', '0004_convert_stats_to_units'),
]
operations = [
migrations.AlterField(
model_name='dinosaur',
name='intelligence',
field=models.PositiveSmallIntegerField(default=50, help_text='Score 0-100'),
),
]
@@ -0,0 +1,14 @@
# Generated by Django 6.0 on 2025-12-05 10:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cards', '0005_add_background_fk'),
('cards', '0005_alter_dinosaur_intelligence'),
]
operations = [
]
+137 -5
View File
@@ -19,12 +19,24 @@ class Dinosaur(models.Model):
diet = models.CharField(max_length=32, choices=DIET_CHOICES, default='herbivore')
description = models.TextField(blank=True)
image = models.ImageField(upload_to='dinosaurs/', blank=True, null=True)
background = models.ForeignKey(
'cards.BackgroundImage',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='dinosaurs',
help_text='Optional per-card background image'
)
# Top-trump style stats (0-100)
speed = models.PositiveSmallIntegerField(default=50)
weight = models.PositiveSmallIntegerField(default=50)
intelligence = models.PositiveSmallIntegerField(default=50)
height = models.PositiveSmallIntegerField(default=50)
# Stats stored in real-world units now:
# - `speed` in km/h
# - `weight` in kg
# - `height` in metres
# - `intelligence` remains a 0-100 score
speed = models.PositiveIntegerField(default=30, help_text='Speed in km/h')
weight = models.PositiveIntegerField(default=1000, help_text='Weight in kg')
intelligence = models.PositiveSmallIntegerField(default=50, help_text='Score 0-100')
height = models.DecimalField(max_digits=5, decimal_places=1, default=1.5, help_text='Height in metres')
facts = models.TextField(blank=True, help_text='One fact per line; shown as bullet points on the card')
created_at = models.DateTimeField(auto_now_add=True)
@@ -42,6 +54,126 @@ class Dinosaur(models.Model):
return []
return [f.strip() for f in self.facts.splitlines() if f.strip()]
# -- Unit helpers / display properties -------------------------------------------------
# These map the 0-100 top-trump style values to human-friendly units. The numeric
# mappings are conservative defaults and can be adjusted if you want different ranges.
# Ranges used to convert between stored unit values and the 0-100 bar/star scale.
SPEED_MAX_KMH = 60.0
WEIGHT_MAX_KG = 15000.0
HEIGHT_MAX_M = 12.0
# Display helpers (use stored unit values directly)
@property
def speed_display(self):
return f"{self.speed} km/h"
@property
def weight_display(self):
kg = int(self.weight)
if kg >= 1000:
tonnes = round(kg / 1000.0, 1)
return f"{tonnes} t"
return f"{kg} kg"
@property
def height_display(self):
# height is a DecimalField; format to one decimal place
return f"{float(self.height):.1f} m"
@property
def intelligence_display(self):
return f"{self.intelligence}%"
# Percent helpers: convert stored units back to a 0-100 scale for bars/stars
def speed_percent(self):
return min(100, int(round(self.speed / float(self.SPEED_MAX_KMH) * 100)))
def weight_percent(self):
return min(100, int(round(self.weight / float(self.WEIGHT_MAX_KG) * 100)))
def height_percent(self):
return min(100, int(round(float(self.height) / float(self.HEIGHT_MAX_M) * 100)))
def intelligence_percent(self):
return min(100, int(self.intelligence or 0))
# -- Star rating helpers -------------------------------------------------------------
def _star_rating_from_percent(self, percent):
"""Convert a 0-100 percent into a 0-5 star score in 0.5 increments."""
if percent is None:
return 0.0
val = float(percent) * 0.05 # scale 0-100 -> 0-5
# round to nearest 0.5
return round(val * 2) / 2.0
def _stars_string(self, percent):
"""Return a simple star string like '★★★★½' and trailing empty stars ''."""
stars = self._star_rating_from_percent(percent)
full = int(stars)
half = 1 if (stars - full) >= 0.5 else 0
empty = 5 - full - half
return "" * full + ("½" if half else "") + "" * empty
def _star_types_from_percent(self, percent):
"""Return a list of five items: 'full', 'half', or 'empty'."""
stars = self._star_rating_from_percent(percent)
full = int(stars)
half = 1 if (stars - full) >= 0.5 else 0
types = ['full'] * full
if half:
types.append('half')
types.extend(['empty'] * (5 - len(types)))
return types
# Expose star strings and original values for templates
@property
def speed_stars(self):
return self._stars_string(self.speed_percent())
@property
def speed_star_types(self):
return self._star_types_from_percent(self.speed_percent())
@property
def speed_orig(self):
return self.speed
@property
def weight_stars(self):
return self._stars_string(self.weight_percent())
@property
def weight_star_types(self):
return self._star_types_from_percent(self.weight_percent())
@property
def weight_orig(self):
return self.weight
@property
def height_stars(self):
return self._stars_string(self.height_percent())
@property
def height_star_types(self):
return self._star_types_from_percent(self.height_percent())
@property
def height_orig(self):
return self.height
@property
def intelligence_stars(self):
return self._stars_string(self.intelligence_percent())
@property
def intelligence_star_types(self):
return self._star_types_from_percent(self.intelligence_percent())
@property
def intelligence_orig(self):
return self.intelligence
class BackgroundImage(models.Model):
"""Background images used behind card renders.
+33 -4
View File
@@ -1,6 +1,6 @@
<article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
<div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;">
{% endif %}
@@ -16,9 +16,38 @@
</ul>
{% endif %}
<div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div>
<div class="stat-row">
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
</div>
<div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
+33 -5
View File
@@ -7,7 +7,7 @@
<div style="display:flex; justify-content:center;">
<div class="toptrump-card">
{# Hero / image banner using background image if available #}
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
<div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="position:relative; display:block; margin:18px auto 0; max-width:78%; height:auto; object-fit:contain; box-shadow:0 6px 18px rgba(0,0,0,0.35); border-radius:6px;">
{% endif %}
@@ -29,10 +29,38 @@
{% endif %}
<div style="margin-top:0.8rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.speed }}</div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.weight }}</div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.intelligence }}</div></div>
<div class="stat-row"><div class="stat-label">Height</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.height }}%"></div></div><div style="width:36px;text-align:right;font-weight:700">{{ card.height }}</div></div>
<div class="stat-row">
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.speed_orig }})</span></div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.weight_orig }})</span></div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div class="is-size-7" style="font-weight:600">{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:700">
<div>{{ card.height_display }}</div>
<div class="is-size-7" style="font-weight:500">{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %} <span style="margin-left:6px">(orig: {{ card.height_orig }})</span></div>
</div>
</div>
</div>
</div>
</div>
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

+44 -4
View File
@@ -1,6 +1,6 @@
<article class="card-item" id="card-{{ card.pk }}">
<div class="toptrump-card">
<div class="hero-image" style="background-image: url('{% if background and background.image %}{{ background.image.url }}{% endif %}');">
<div class="hero-image" style="background-image: url('{% if card.background and card.background.image %}{{ card.background.image.url }}{% elif background and background.image %}{{ background.image.url }}{% endif %}');">
{% if card.image %}
<img src="{{ card.image.url }}" alt="{{ card.name }}" style="display:block;margin:16px auto 0; max-width:72%; height:auto; object-fit:cover; box-shadow:0 4px 12px rgba(0,0,0,0.3); border-radius:6px;">
{% endif %}
@@ -16,9 +16,49 @@
</ul>
{% endif %}
<div style="margin-top:0.5rem">
<div class="stat-row"><div class="stat-label">Speed</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Weight</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight }}%"></div></div></div>
<div class="stat-row"><div class="stat-label">Intelligence</div><div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence }}%"></div></div></div>
<div class="stat-row">
<div class="stat-label">Speed <small class="is-size-7">(km/h)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.speed_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.speed_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.speed_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.speed_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Weight <small class="is-size-7">(kg)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.weight_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.weight_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.weight_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.weight_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Height <small class="is-size-7">(m)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.height_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div>{{ card.height_display }}</div>
<div class="is-size-7" style="font-weight:500">
{% for t in card.height_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
<span style="margin-left:6px">(orig: {{ card.height_orig }})</span>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-label">Intelligence <small class="is-size-7">(score)</small></div>
<div class="stat-bar"><div class="stat-fill" style="width:{{ card.intelligence_percent }}%"></div></div>
<div class="stat-value" style="margin-left:8px;font-weight:600">
<div class="is-size-7" style="font-weight:600">
{% for t in card.intelligence_star_types %}<span class="star {{ t }}">{% if t == 'full' %}★{% else %}☆{% endif %}</span>{% endfor %}
</div>
<div class="is-size-7" style="font-weight:500">(orig: {{ card.intelligence_orig }})</div>
</div>
</div>
</div>
<div class="buttons" style="margin-top:0.6rem">
<button class="button is-small is-info" hx-get="{% url 'cards:edit' card.pk %}" hx-target="#htmx-form" hx-swap="innerHTML">Edit</button>
+32 -4
View File
@@ -86,19 +86,47 @@
<div class="field-body">
<div class="field">
<label class="label">{{ form.speed.label }}</label>
<div class="control">{{ form.speed }}</div>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.speed }}</div>
<div class="control">
<a class="button is-static">km/h</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.weight.label }}</label>
<div class="control">{{ form.weight }}</div>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.weight }}</div>
<div class="control">
<a class="button is-static">kg</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.intelligence.label }}</label>
<div class="control">{{ form.intelligence }}</div>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.intelligence }}</div>
<div class="control">
<a class="button is-static">score</a>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ form.height.label }}</label>
<div class="control">{{ form.height }}</div>
<div class="control">
<div class="field has-addons">
<div class="control is-expanded">{{ form.height }}</div>
<div class="control">
<a class="button is-static">m</a>
</div>
</div>
</div>
</div>
</div>
</div>
+7
View File
@@ -41,6 +41,13 @@
.toptrump-card .stat-bar{flex:1;height:12px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden}
.toptrump-card .stat-fill{height:100%;background:linear-gradient(90deg,var(--accent),#ffdd57);}
.toptrump-card .fact-list{margin-top:0.5rem;padding-left:1rem}
/* Star rating visuals */
.star { font-size:0.95rem; display:inline-block; line-height:1; margin-left:2px; }
.star.full { color:#ffdd57; }
.star.empty { color:rgba(255,255,255,0.25); }
.star.half { color:rgba(255,255,255,0.25); position:relative; display:inline-block; }
.star.half::before { content: '★'; color:#ffdd57; position:absolute; left:0; top:0; width:50%; overflow:hidden; display:inline-block; }
.stat-value { text-align:right; min-width:6.5rem }
</style>
</head>
<body>