All checks were successful
Reindex Knowledge Base / reindex (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
403 lines
17 KiB
Markdown
403 lines
17 KiB
Markdown
# 8. Discord Display, Visual Identity, and Animated Cards
|
|
|
|
[< Back to Index](README.md) | [Next: Integrations >](09-integrations.md)
|
|
|
|
---
|
|
|
|
## 8.1 Tier Visual Indicators
|
|
|
|
Evolved cards display visual indicators in Discord embeds that signal evolution status without
|
|
requiring image regeneration (which is expensive and slow):
|
|
|
|
| Evolution State | Embed Change |
|
|
|---|---|
|
|
| Not evolved (T0) | Standard embed, no change |
|
|
| T1 complete | Small badge emoji appended to card name field: `Mike Trout [T1]` |
|
|
| T2 complete | Badge updates: `Mike Trout [T2]` + embed accent color changes to gold |
|
|
| T3 complete | Badge: `Mike Trout [T3]` + embed color changes to purple |
|
|
| T4 full evolved | Name changes to `[Team]'s Evolved Mike Trout` + embed color changes to teal |
|
|
|
|
> **TBD:** Tier badge icons are placeholders and likely to change. Current candidates:
|
|
> T1 = `:seedling:`, T2 = `:star:`, T3 = `:gem:`, T4 = `:crown:`. Final icons will be
|
|
> chosen during implementation based on Discord rendering quality and visual distinctiveness.
|
|
|
|
## 8.2 Evolution Status Embed
|
|
|
|
A dedicated `!evo status` command (or equivalent slash command) displays evolution status for
|
|
all cards on the roster (or a specific card):
|
|
|
|
```
|
|
[Team Name]'s Evolution Progress
|
|
================================
|
|
Mike Trout (All-Star) — T2 in progress
|
|
Standard Batter Track
|
|
Tier 2 milestones:
|
|
[x] Win 8 games with card in lineup (8/8)
|
|
[x] Hit 5 HR (5/5)
|
|
[ ] Reach base 30 times (22/30) <-- in progress
|
|
Tier 2 unlocks: +1.0 budget (contact profile)
|
|
Boost profile: auto-contact
|
|
|
|
Babe Ruth (HoF) — T1 complete, T2 in progress
|
|
Legend Batter Track
|
|
...
|
|
|
|
Type !evo card <card_id> for detailed milestone breakdown.
|
|
Type !evo cosmetics <card_id> to browse premium visual upgrades.
|
|
```
|
|
|
|
## 8.3 Milestone Completion Notification
|
|
|
|
When a milestone is completed, the bot sends a notification in the team's designated channel:
|
|
|
|
```
|
|
[Seedling] Evolution milestone completed!
|
|
Mike Trout — T1, Milestone 2: "Record 15 Hits" COMPLETE
|
|
Tier 1 is now fully complete! Rating boost applied: +1.0 budget (contact profile)
|
|
[Mike Trout's card is now Tier 1 Evolved]
|
|
```
|
|
|
|
When a full tier is completed and the rarity upgrade fires:
|
|
```
|
|
[Crown] FULL EVOLUTION COMPLETE!
|
|
"[TeamName]'s Evolved Mike Trout" — Starter -> All-Star Upgrade Applied!
|
|
Rating boost: +1.0 budget capstone applied (4.0 total across all tiers).
|
|
This card is now permanently upgraded on your roster.
|
|
```
|
|
|
|
## 8.4 Premium Cosmetics Display
|
|
|
|
Premium cosmetics purchased via the currency store apply visual upgrades to the card's Discord
|
|
embed beyond the standard tier badge system:
|
|
|
|
For the full cosmetics catalog with pricing, see [10-economy.md § 10.3](10-economy.md#103-cosmetics-pricing).
|
|
|
|
| Cosmetic | Effect | Image Format |
|
|
|---|---|---|
|
|
| Gold Frame | Card embed styled with gold border accent | Static PNG |
|
|
| Diamond Frame | Diamond shimmer gradient border | Static PNG |
|
|
| Team Color Frame | Solid team-color border | Static PNG |
|
|
| Holographic Frame | Animated rainbow gradient border shimmer | **Animated APNG** |
|
|
| Dark Mode Theme | Charcoal header + dark column backgrounds | Static PNG |
|
|
| Midnight Theme | Deep navy/black column backgrounds | Static PNG |
|
|
| Prestige Badge | T4 badge upgraded from `:crown:` to `:crown::sparkles:` with custom label | Static PNG |
|
|
| Rarity Glow (Subtle) | Subtle pulsing glow matching rarity color | **Animated APNG** |
|
|
| Rarity Glow (Strong) | Intense pulsing glow matching rarity color | **Animated APNG** |
|
|
| Full Art Reveal | Custom card art background | Static PNG |
|
|
|
|
Cosmetics are purchased per `player_id` for a specific team. Since `player_id` maps 1:many with
|
|
`card_id`, purchasing a cosmetic for a player applies to all duplicate cards of that player on
|
|
the team — the variant hash is recomputed for all of them. Cosmetics are attached to the
|
|
`evolution_card_state` record (keyed by `player_id + team_id`), so duplicates always share the
|
|
same visual treatment.
|
|
|
|
When a card is traded, the new owner does **not** inherit purchased cosmetics — those remain
|
|
with the selling team's evolution state. The traded card's cosmetic display reverts to the
|
|
standard tier badge for the new owner.
|
|
|
|
This creates a flex incentive: players invest in cosmetics for cards they intend to keep and show
|
|
off. It also means a card with premium cosmetics is visually distinctive in gauntlet brackets
|
|
and trading discussions, providing social proof of investment.
|
|
|
|
## 8.5 Card Image Generation for Variants (Static)
|
|
|
|
When a new variant is created (tier completion or cosmetic purchase), the system:
|
|
|
|
1. Computes the variant hash from `(player_id, evolution_tier, cosmetics)`
|
|
2. Checks if a `battingcard` row with that hash already exists — if so, reuses it
|
|
3. If not, creates the variant's rating rows (base + evolution boosts applied)
|
|
4. Determines whether the variant requires animation (see § 8.6) or is static PNG
|
|
5. For **static variants**: renders the card via the existing Playwright pipeline, with cosmetic
|
|
CSS injected into the HTML template (frame overlays, background gradients, evolution badge, etc.)
|
|
6. Uploads the PNG to S3 at `cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png`
|
|
7. Stores the S3 URL in `battingcard.image_url`
|
|
8. Updates `card.variant` on all card instances that should use this variant
|
|
|
|
The image format (PNG vs APNG) is determined by the file extension in the S3 URL stored in
|
|
`battingcard.image_url` — no separate format column is needed. The bot and Discord both handle
|
|
`.png` and `.apng` URLs identically.
|
|
|
|
The ~5 second render + upload cost is paid **once per variant creation**, not on every display.
|
|
Subsequent displays are instant — the bot reads `battingcard.image_url` and puts it in the embed
|
|
just like it reads `player.image` today.
|
|
|
|
**Cosmetic CSS injection** is handled by passing cosmetic parameters to the card rendering
|
|
endpoint. The HTML template conditionally applies CSS classes based on cosmetic flags:
|
|
- Frame overlays → CSS border/outline/box-shadow on the card container
|
|
- Background themes → replace hardcoded `#ACE6FF`/`#EAA49C` with themed colors
|
|
- Evolution badge → additional `<img>` element alongside the rarity badge
|
|
- Header gradient → custom gradient colors replacing the standard blue/red
|
|
|
|
See [14-risks.md § Open Question 9](14-risks.md) for CSS implementation approach discussion.
|
|
|
|
---
|
|
|
|
## 8.6 Animated Card Pipeline
|
|
|
|
### 8.6.1 Why APNG
|
|
|
|
Several cosmetic effects benefit from animation: holographic frame shimmer, rarity glow pulses,
|
|
evolution badge sparkle effects. The format choice matters:
|
|
|
|
| Format | Discord Embed | Browser `<img>` | Color Depth | File Size |
|
|
|---|---|---|---|---|
|
|
| **APNG** | Auto-plays, full support | Native, all browsers | Full 24-bit + alpha | ~1-4 MB for 8-12 frames |
|
|
| GIF | Auto-plays | Native | **256 colors** — visible banding on gradients | ~0.5-2 MB |
|
|
| Animated WebP | Works in most clients | Native, modern browsers | Full color | ~0.8-3 MB |
|
|
| MP4/WebM | **Not supported** in `set_image` | Requires `<video>` tag | Full color | Smallest |
|
|
|
|
**APNG wins** because it preserves the full PNG color depth our cards rely on (subtle gradients
|
|
in the blue/red columns, rarity badge details) while working natively in Discord embeds and
|
|
all modern browsers. GIF's 256-color limit would visibly degrade card quality. MP4/WebM cannot
|
|
be used in Discord embed images at all.
|
|
|
|
### 8.6.2 Which Cosmetics Trigger Animation
|
|
|
|
Not all cosmetics need animation. Only cosmetics that involve visible motion produce APNG output.
|
|
All others remain static PNG (smaller file, faster to render).
|
|
|
|
| Cosmetic | Animated? | Effect Description |
|
|
|---|---|---|
|
|
| Holographic Frame | Yes | Rainbow gradient shifts position across frames |
|
|
| Rarity Glow (Subtle) | Yes | Border glow opacity pulses 0.3 → 0.6 → 0.3 over ~2 seconds |
|
|
| Rarity Glow (Strong) | Yes | Border glow opacity pulses 0.5 → 1.0 → 0.5, larger spread |
|
|
| T4 Crown Badge sparkle | Yes | Small sparkle particles rotate around the crown icon |
|
|
| Gold Frame | No | Static gold border + subtle shadow |
|
|
| Diamond Frame | No | Static diamond-gradient border |
|
|
| Dark/Midnight themes | No | Static background color swap |
|
|
| Team Color Frame | No | Static solid border |
|
|
|
|
When a variant includes **any** animated cosmetic, the entire card is rendered as APNG.
|
|
When all cosmetics are static, the card stays PNG.
|
|
|
|
### 8.6.3 Render Pipeline
|
|
|
|
The animated card pipeline builds on the **persistent browser** optimization described in
|
|
[02-architecture.md](02-architecture.md#card-render-pipeline-optimization). Rather than launching
|
|
a new Chromium process, frames are captured using a page from the shared browser instance.
|
|
|
|
The pipeline uses **deterministic frame control** via a CSS custom property (`--anim-progress`)
|
|
rather than relying on CSS animation timing. This guarantees identical output across renders and
|
|
eliminates timing jitter.
|
|
|
|
```
|
|
1. Get page from persistent browser (shared instance, ~2ms)
|
|
2. Load HTML template with cosmetic CSS + animation custom properties
|
|
3. For each frame i in range(N):
|
|
a. Set --anim-progress = i / N (0.0 → 0.9)
|
|
b. CSS calc() expressions update all animated properties from progress value
|
|
c. page.screenshot() captures 1200x600 PNG frame buffer
|
|
4. Close page (~2ms)
|
|
5. Assemble frames into APNG using Python apng library
|
|
- Frame delay: 166ms (6 FPS) for smooth loop without excessive file size
|
|
- Loop count: 0 (infinite)
|
|
6. Optional: pngquant-compress each frame before assembly if over file size budget
|
|
7. Upload APNG to S3 with content-type image/apng
|
|
8. Store URL in battingcard.image_url (format inferred from .apng extension)
|
|
```
|
|
|
|
**Frame count targets by effect:**
|
|
|
|
| Effect | Frames | Duration | Rationale |
|
|
|---|---|---|---|
|
|
| Holographic shimmer | 12 | 2.0s | Full gradient cycle needs more frames for smoothness |
|
|
| Rarity glow pulse | 8 | 1.3s | Simple opacity fade needs fewer frames |
|
|
| Badge sparkle | 10 | 1.7s | Particle rotation at medium smoothness |
|
|
|
|
**Performance (with persistent browser):** Each screenshot takes ~0.4s with the persistent
|
|
browser (no launch overhead). Assembly adds ~1s.
|
|
|
|
| Variant Type | Frames | Capture Time | Assembly | Total |
|
|
|---|---|---|---|---|
|
|
| Static PNG | 1 | ~0.4s | — | ~0.6s |
|
|
| 8-frame APNG | 8 | ~3.2s | ~1.0s | ~4.5s |
|
|
| 12-frame APNG | 12 | ~4.8s | ~1.0s | ~6.0s |
|
|
|
|
Since animated cosmetics are premium purchases (not bulk operations), the volume is low —
|
|
estimated <50 animated variants per month at steady state.
|
|
|
|
### 8.6.4 Deterministic Frame Control
|
|
|
|
Animated card rendering uses a **custom CSS property** (`--anim-progress`) to drive all
|
|
animation state, rather than relying on CSS `animation` timing. This ensures:
|
|
|
|
- **Reproducible output:** Same inputs always produce identical frames
|
|
- **No timing jitter:** No dependency on Playwright's internal clock or screenshot timing
|
|
- **Simpler debugging:** Each frame's visual state is fully determined by a single `0.0 → 1.0` value
|
|
|
|
**Python frame capture loop:**
|
|
|
|
```python
|
|
async def capture_animated_frames(
|
|
page: Page,
|
|
html_content: str,
|
|
frame_count: int = 10,
|
|
) -> list[bytes]:
|
|
"""Capture N deterministic animation frames from a card template.
|
|
|
|
Each frame sets --anim-progress to i/frame_count, which CSS calc()
|
|
expressions use to compute all animated property values.
|
|
"""
|
|
await page.set_content(html_content)
|
|
frames = []
|
|
|
|
for i in range(frame_count):
|
|
progress = i / frame_count # 0.0, 0.1, 0.2, ... 0.9
|
|
await page.evaluate(
|
|
f"document.documentElement.style.setProperty('--anim-progress', '{progress}')"
|
|
)
|
|
frame = await page.screenshot(
|
|
type="png",
|
|
clip={"x": 0, "y": 0, "width": 1200, "height": 600},
|
|
)
|
|
frames.append(frame)
|
|
|
|
return frames
|
|
```
|
|
|
|
**CSS using `--anim-progress` (replaces @keyframes):**
|
|
|
|
```css
|
|
:root {
|
|
--anim-progress: 0; /* set by Python per frame: 0.0 → 0.9 */
|
|
}
|
|
|
|
/* Holographic frame shimmer — gradient position driven by progress */
|
|
.frame-holographic {
|
|
border: 4px solid transparent;
|
|
background: linear-gradient(
|
|
135deg,
|
|
#ff0000, #ff8800, #ffff00, #00ff00,
|
|
#0088ff, #8800ff, #ff0088, #ff0000
|
|
) border-box;
|
|
background-size: 400% 400%;
|
|
background-position: calc(var(--anim-progress) * 400%) 50%;
|
|
}
|
|
|
|
/* Rarity glow pulse — opacity follows a sine-like curve over progress
|
|
Approximation: triangle wave via abs(progress - 0.5) * 2
|
|
At progress=0: opacity=0.3, progress=0.5: opacity=0.8, progress=1: opacity=0.3 */
|
|
.glow-subtle {
|
|
--glow-intensity: calc(0.3 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
|
|
box-shadow: 0 0 calc(8px + 12px * var(--glow-intensity)) calc(2px + 4px * var(--glow-intensity)) var(--rarity-color);
|
|
opacity: var(--glow-intensity);
|
|
}
|
|
|
|
.glow-strong {
|
|
--glow-intensity: calc(0.5 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
|
|
box-shadow: 0 0 calc(12px + 20px * var(--glow-intensity)) calc(4px + 8px * var(--glow-intensity)) var(--rarity-color);
|
|
opacity: var(--glow-intensity);
|
|
}
|
|
|
|
/* T4 Crown badge sparkle — rotation driven by progress */
|
|
.badge-sparkle {
|
|
transform: rotate(calc(var(--anim-progress) * 360deg));
|
|
}
|
|
```
|
|
|
|
**Why not CSS @keyframes?** CSS animation timing in headless Chromium depends on internal clock
|
|
ticks between screenshots. Two renders of the same card could produce slightly different frames
|
|
if the timing drifts. The `--anim-progress` approach makes each frame's visual state a pure
|
|
function of its index — deterministic and reproducible.
|
|
|
|
### 8.6.5 APNG Assembly
|
|
|
|
Python assembly using the `apng` library:
|
|
|
|
```python
|
|
from apng import APNG, PNG
|
|
|
|
def assemble_apng(frame_buffers: list[bytes], delay_ms: int = 166) -> bytes:
|
|
"""Assemble PNG frame buffers into an APNG.
|
|
|
|
Args:
|
|
frame_buffers: List of PNG image bytes (one per frame).
|
|
delay_ms: Delay between frames in milliseconds.
|
|
|
|
Returns:
|
|
APNG file bytes ready for S3 upload.
|
|
"""
|
|
apng = APNG()
|
|
for buf in frame_buffers:
|
|
png = PNG.from_bytes(buf)
|
|
apng.append(png, delay=delay_ms, delay_den=1000)
|
|
output = io.BytesIO()
|
|
apng.save(output)
|
|
return output.getvalue()
|
|
```
|
|
|
|
**Dependencies:** `pip install apng` (pure Python, no native deps). The `apng` library handles
|
|
APNG chunk encoding per the PNG spec extension. No need for external tools like `apngasm`.
|
|
|
|
### 8.6.6 S3 Storage and Content-Type
|
|
|
|
Animated cards use `.apng` extension and `image/apng` content type:
|
|
|
|
```python
|
|
s3_key = f"cards/cardset-{cardset_id:03d}/player-{player_id}/v{variant}/battingcard.apng"
|
|
|
|
await s3_client.put_object(
|
|
Bucket=bucket,
|
|
Key=s3_key,
|
|
Body=apng_bytes,
|
|
ContentType="image/apng",
|
|
CacheControl="public, max-age=31536000", # immutable variant images
|
|
)
|
|
```
|
|
|
|
Discord and browsers handle `image/apng` identically to `image/png` for display purposes — the
|
|
APNG spec is a backward-compatible extension of PNG. A client that doesn't support APNG will
|
|
display the first frame as a static PNG, which is an acceptable graceful degradation.
|
|
|
|
### 8.6.7 Bot Display Logic Update
|
|
|
|
The bot's image resolution needs a minor update to handle both formats:
|
|
|
|
```python
|
|
if card.variant != 0:
|
|
bc = BattingCard.get(player_id=card.player_id, variant=card.variant)
|
|
image_url = bc.image_url # could be .png or .apng — Discord handles both
|
|
else:
|
|
image_url = card.player.image # always static PNG
|
|
|
|
embed.set_image(url=image_url) # Discord auto-plays APNG in embeds
|
|
```
|
|
|
|
No format-specific logic needed in the bot — Discord's embed renderer handles APNG auto-play
|
|
transparently. The image format is inferred from the URL extension (`.png` vs `.apng`) when
|
|
the render pipeline needs to decide whether to re-render as animated.
|
|
|
|
### 8.6.8 File Size Budget
|
|
|
|
Target file sizes to keep Discord embeds responsive:
|
|
|
|
| Type | Target | Max |
|
|
|---|---|---|
|
|
| Static PNG | ~150-250 KB | 500 KB |
|
|
| Animated APNG (8 frames) | ~1.0-2.0 MB | 4 MB |
|
|
| Animated APNG (12 frames) | ~1.5-3.0 MB | 5 MB |
|
|
|
|
Discord's embed image limit is 25 MB, so we have significant headroom. However, large images
|
|
slow down embed rendering on mobile clients. The targets above keep load times under 1 second
|
|
on typical connections.
|
|
|
|
**Optimization options if sizes run high:**
|
|
- Reduce frame count (8 instead of 12 — less smooth but much smaller)
|
|
- Use `pngquant` to lossy-compress each frame before assembly (can cut 50-70% with minimal
|
|
visual impact on card images)
|
|
- Only animate the border/glow region and keep the card body static (composite optimization)
|
|
|
|
### 8.6.9 Animated Card Cosmetics Pricing
|
|
|
|
Animated cosmetics carry a premium over static equivalents, reflecting both the visual impact
|
|
and the higher render cost:
|
|
|
|
| Cosmetic | Static Equivalent | Animated Price | Premium Over Static |
|
|
|---|---|---|---|
|
|
| Holographic Frame | Gold Frame (800₼) | 2,000₼ | +150% |
|
|
| Rarity Glow (Subtle) | — (no static equiv) | 1,200₼ | — |
|
|
| Rarity Glow (Strong) | Rarity Glow Subtle (1,200₼) | 1,800₼ | +50% |
|
|
|
|
The higher price point makes animated cards a genuine flex — visible rarity in gauntlet brackets
|
|
and trading negotiations.
|