256 lines
8.8 KiB
Python
256 lines
8.8 KiB
Python
from django.db import models
|
|
|
|
|
|
class Dinosaur(models.Model):
|
|
ERA_CHOICES = [
|
|
('triassic', 'Triassic'),
|
|
('jurassic', 'Jurassic'),
|
|
('cretaceous', 'Cretaceous'),
|
|
]
|
|
|
|
DIET_CHOICES = [
|
|
('herbivore', 'Herbivore'),
|
|
('carnivore', 'Carnivore'),
|
|
('omnivore', 'Omnivore'),
|
|
]
|
|
|
|
name = models.CharField(max_length=120)
|
|
era = models.CharField(max_length=32, choices=ERA_CHOICES, default='jurassic')
|
|
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'
|
|
)
|
|
|
|
# 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)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at', 'name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@property
|
|
def facts_list(self):
|
|
"""Return facts as a list of non-empty lines."""
|
|
if not self.facts:
|
|
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
|
|
pct = float(percent)
|
|
# scale 0-100 -> 0-5
|
|
val = pct * 0.05
|
|
# round to nearest 0.5
|
|
rounded = round(val * 2) / 2.0
|
|
# Avoid promoting near-100 values to 5.0 via rounding. Only an
|
|
# exact 100% should map to a full 5.0. Anything less will be
|
|
# capped at 4.5 to keep 5-star visually reserved for true maxima.
|
|
if pct >= 100.0:
|
|
return 5.0
|
|
return min(rounded, 4.5)
|
|
|
|
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
|
|
|
|
@property
|
|
def background_image_scale(self):
|
|
"""Return a CSS background-size percentage influenced by the
|
|
dinosaur's stored height. Uses `height_percent()` to derive a
|
|
value in a reasonable range so larger dinosaurs get a larger
|
|
background image scale.
|
|
|
|
The result is a string like '60%'. Keep the value clamped to
|
|
avoid extremely small/large sizes.
|
|
"""
|
|
try:
|
|
# Use a combined metric (height + weight) to smooth out extremes
|
|
h = float(self.height_percent() or 0)
|
|
w = float(self.weight_percent() or 0)
|
|
p = (h + w) / 2.0
|
|
except Exception:
|
|
p = 50.0
|
|
# Use much smaller range for subtler effect: largest dinos -> 100%
|
|
# (full image), smallest -> 140% (mild zoom). Interpolate linearly.
|
|
min_scale = 100
|
|
max_scale = 140
|
|
scale = int(round(min_scale + (1.0 - (p / 100.0)) * (max_scale - min_scale)))
|
|
scale = max(min_scale, min(max_scale, scale))
|
|
return f"{scale}%"
|
|
|
|
@property
|
|
def image_scale(self):
|
|
"""Return a scale factor (float) for the dinosaur's image size.
|
|
|
|
Smaller dinosaurs will be scaled down (e.g. 0.6) and larger
|
|
dinosaurs closer to full size (1.0). Uses `height_percent()`.
|
|
"""
|
|
try:
|
|
# Combine height and weight for a smoother scale value
|
|
h = float(self.height_percent() or 0)
|
|
w = float(self.weight_percent() or 0)
|
|
p = (h + w) / 2.0
|
|
except Exception:
|
|
p = 50.0
|
|
# Subtle scaling: smallest -> 0.90, largest -> 1.00
|
|
min_scale = 0.90
|
|
max_scale = 1.00
|
|
scale = min_scale + (p / 100.0) * (max_scale - min_scale)
|
|
scale = max(min_scale, min(max_scale, scale))
|
|
return f"{scale:.2f}"
|
|
|
|
|
|
class BackgroundImage(models.Model):
|
|
"""Background images used behind card renders.
|
|
|
|
Admins can upload multiple backgrounds and mark one as active. Templates
|
|
will use BackgroundImage.get_active() to obtain the active background.
|
|
"""
|
|
title = models.CharField(max_length=200, blank=True)
|
|
image = models.ImageField(upload_to='backgrounds/')
|
|
is_active = models.BooleanField(default=False, help_text='Mark as the active background')
|
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['-is_active', '-uploaded_at']
|
|
|
|
def __str__(self):
|
|
return self.title or f"Background #{self.pk}"
|
|
|
|
@classmethod
|
|
def get_active(cls):
|
|
"""Return the active BackgroundImage if set, otherwise the most recent one or None."""
|
|
active = cls.objects.filter(is_active=True).order_by('-uploaded_at').first()
|
|
if active:
|
|
return active
|
|
return cls.objects.order_by('-uploaded_at').first()
|