diff --git a/cards/forms.py b/cards/forms.py index 1b0f64a..296dbe3 100644 --- a/cards/forms.py +++ b/cards/forms.py @@ -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 diff --git a/cards/migrations/0004_convert_stats_to_units.py b/cards/migrations/0004_convert_stats_to_units.py new file mode 100644 index 0000000..89e4940 --- /dev/null +++ b/cards/migrations/0004_convert_stats_to_units.py @@ -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), + ] diff --git a/cards/migrations/0005_add_background_fk.py b/cards/migrations/0005_add_background_fk.py new file mode 100644 index 0000000..c415168 --- /dev/null +++ b/cards/migrations/0005_add_background_fk.py @@ -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'), + ), + ] diff --git a/cards/migrations/0005_alter_dinosaur_intelligence.py b/cards/migrations/0005_alter_dinosaur_intelligence.py new file mode 100644 index 0000000..614377e --- /dev/null +++ b/cards/migrations/0005_alter_dinosaur_intelligence.py @@ -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'), + ), + ] diff --git a/cards/migrations/0006_merge_20251205_1039.py b/cards/migrations/0006_merge_20251205_1039.py new file mode 100644 index 0000000..02c832c --- /dev/null +++ b/cards/migrations/0006_merge_20251205_1039.py @@ -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 = [ + ] diff --git a/cards/models.py b/cards/models.py index dada2f0..57a1826 100644 --- a/cards/models.py +++ b/cards/models.py @@ -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. diff --git a/cards/templates/cards/_card_item.html b/cards/templates/cards/_card_item.html index 0a458c6..5198859 100644 --- a/cards/templates/cards/_card_item.html +++ b/cards/templates/cards/_card_item.html @@ -1,6 +1,6 @@
-
+
{% if card.image %} {{ card.name }} {% endif %} @@ -16,9 +16,38 @@ {% endif %}
-
Speed
-
Weight
-
Intelligence
+
+
Speed (km/h)
+
+
+
{{ card.speed_display }}
+
+ {% for t in card.speed_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} + (orig: {{ card.speed_orig }}) +
+
+
+
+
Weight (kg)
+
+
+
{{ card.weight_display }}
+
+ {% for t in card.weight_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} + (orig: {{ card.weight_orig }}) +
+
+
+
+
Intelligence (score)
+
+
+
+ {% for t in card.intelligence_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} +
+
(orig: {{ card.intelligence_orig }})
+
+
diff --git a/cards/templates/cards/_card_preview.html b/cards/templates/cards/_card_preview.html index 39b71bc..c71045b 100644 --- a/cards/templates/cards/_card_preview.html +++ b/cards/templates/cards/_card_preview.html @@ -7,7 +7,7 @@
{# Hero / image banner using background image if available #} -
+
{% if card.image %} {{ card.name }} {% endif %} @@ -29,10 +29,38 @@ {% endif %}
-
Speed
{{ card.speed }}
-
Weight
{{ card.weight }}
-
Intelligence
{{ card.intelligence }}
-
Height
{{ card.height }}
+
+
Speed (km/h)
+
+
+
{{ card.speed_display }}
+
{% for t in card.speed_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} (orig: {{ card.speed_orig }})
+
+
+
+
Weight (kg)
+
+
+
{{ card.weight_display }}
+
{% for t in card.weight_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} (orig: {{ card.weight_orig }})
+
+
+
+
Intelligence (score)
+
+
+
{% for t in card.intelligence_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %}
+
(orig: {{ card.intelligence_orig }})
+
+
+
+
Height (m)
+
+
+
{{ card.height_display }}
+
{% for t in card.height_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} (orig: {{ card.height_orig }})
+
+
diff --git a/db.sqlite3 b/db.sqlite3 index 5c26d24..06bfd30 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/media/dinosaurs/trex.png b/media/dinosaurs/trex.png new file mode 100644 index 0000000..84cba3d Binary files /dev/null and b/media/dinosaurs/trex.png differ diff --git a/templates/cards/_card_item.html b/templates/cards/_card_item.html index 0a458c6..bf16a5c 100644 --- a/templates/cards/_card_item.html +++ b/templates/cards/_card_item.html @@ -1,6 +1,6 @@
-
+
{% if card.image %} {{ card.name }} {% endif %} @@ -16,9 +16,49 @@ {% endif %}
-
Speed
-
Weight
-
Intelligence
+
+
Speed (km/h)
+
+
+
{{ card.speed_display }}
+
+ {% for t in card.speed_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} + (orig: {{ card.speed_orig }}) +
+
+
+
+
Weight (kg)
+
+
+
{{ card.weight_display }}
+
+ {% for t in card.weight_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} + (orig: {{ card.weight_orig }}) +
+
+
+
+
Height (m)
+
+
+
{{ card.height_display }}
+
+ {% for t in card.height_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} + (orig: {{ card.height_orig }}) +
+
+
+
+
Intelligence (score)
+
+
+
+ {% for t in card.intelligence_star_types %}{% if t == 'full' %}★{% else %}☆{% endif %}{% endfor %} +
+
(orig: {{ card.intelligence_orig }})
+
+
diff --git a/templates/cards/_form.html b/templates/cards/_form.html index 3d79e23..a4e3e0a 100644 --- a/templates/cards/_form.html +++ b/templates/cards/_form.html @@ -86,19 +86,47 @@
-
{{ form.speed }}
+
+
+
{{ form.speed }}
+
+ km/h +
+
+
-
{{ form.weight }}
+
+
+
{{ form.weight }}
+
+ kg +
+
+
-
{{ form.intelligence }}
+
+
+
{{ form.intelligence }}
+
+ score +
+
+
-
{{ form.height }}
+
+
+
{{ form.height }}
+
+ m +
+
+
diff --git a/templates/cards/base.html b/templates/cards/base.html index c136402..80adef8 100644 --- a/templates/cards/base.html +++ b/templates/cards/base.html @@ -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 }