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()