More fixes and features to the card-creator #67

Merged
waifu merged 8 commits from card-creator into dev 2025-07-19 21:42:52 -07:00
3 changed files with 198 additions and 12 deletions

89
docs/design.md Normal file
View file

@ -0,0 +1,89 @@
# 🎴 Character Card Standards
This page explains the standard format and rules used for character cards.
---
## 📐 Card Dimensions
- **Canvas Size:** `800x1120px`
- **Print Equivalent:** 2.5 x 3.5 inches (at 300dpi, classic card format)
---
## 🧩 Layout Regions
Each card is composed of the following sections:
- **Top Name Bar** Displays character name
- **Series Text** Indicates the collection or set the card belongs to
- **Main Art Zone** Character illustration
- **Stats Row** Shows 3 core stats:
- **Power**
- **Charm**
- **Wit**
- **Flavor Text Box** Optional quote or description
- **Rarity Marker** Icon, stars, or color code
- **Artist name** - The creator of the Character illustration
- **Kemoverse ID** - Unique SHA-256 code generated for the card, based on the information of the card (Ommiting art)
---
## 🌟 Rarity Tiers
Each card belongs to a **rarity tier** which affects both appearance and stat limits.
| Rarity | Total Stats Cap | Visual Features Recommended |
|--------------|------------------|-------------------------------------|
| **Common** | 100 | Plain template, minimal shine |
| **Uncommon** | 140 | Slightly enhanced frame |
| **Rare** | 180 | Decorative borders, mild shine |
| **Super Rare** | 220 | Unique frame, vibrant shine |
| **Legendary** | 250 | Custom art frames, animated effects |
---
## 🎲 Rarity Distribution Guidelines
The following table shows an **example** of how many cards you might include *in a 20-card pack*, but the actual number of cards per rarity is flexible.
What **matters most** is that rarities follow the **percentage limits** defined below.
These limits ensure balance across the entire card pool — regardless of total size.
| Rarity | Max % Allowed | Example (20 cards) | Weight | Notes |
|--------------|----------------|--------------------|---------|----------------------------------------|
| **Common** | No upper limit | 10 cards (50%) | 0.7 | Can exceed 50% of total if needed |
| **Uncommon** | 20% | 4 cards | 0.2 | Should not exceed 20% of total |
| **Rare** | 15% | 3 cards | 0.08 | Should not exceed 15% of total |
| **Super Rare** | 10% | 2 cards | 0.015 | Should not exceed 10% of total |
| **Legendary** | 5% | 1 card | 0.005 | Should not exceed 5% of total |
> ✅ You can include more cards overall (e.g. 40, 100, etc.),
> 🔒 But rarities from **Uncommon to Legendary** must **not exceed their percentage caps**.
> 🪶 **Common** cards are the only exception — they can scale freely and fill the remaining space.
---
## 🧮 Stat Allocation Rules
Cards have **three core stats**: `Power`, `Charm`, and `Wit`.
- The **sum** of these three stats **must not exceed** the max for the rarity tier.
- Stats can be distributed however you want, as long as the total stays within the cap.
- Minimum stat per field: `0`
- Maximum per stat: Not enforced, but try to keep cards balanced visually and mechanically.
---
## ✨ Rarity Visual Guide
The standard cards have the following :
- 🌱 Common Gray
- 🔸 Uncommon Green
- 🔷 Rare Blue
- 🔮 Epic Gold
- 🌈 Legendary Purple

View file

@ -15,8 +15,20 @@
# along with this program. If not, see https://www.gnu.org/licenses/.
blinker==1.9.0
blurhash==1.1.4
cairocffi==1.7.1
CairoSVG==2.8.2
certifi==2025.4.26
cffi==1.17.1
charset-normalizer==3.4.2
click==8.1.8
cssselect2==0.8.0
decorator==5.2.1
defusedxml==0.7.1
Flask==3.1.0
gunicorn==23.0.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
@ -24,3 +36,14 @@ Werkzeug==3.1.3
Misskey.py==4.1.0
Mastodon.py==1.8.1
filetype==1.2.0
packaging==25.0
pillow==11.2.1
pycparser==2.22
python-dateutil==2.9.0.post0
python-magic==0.4.27
requests==2.32.3
six==1.17.0
tinycss2==1.4.0
urllib3==2.4.0
watchdog==6.0.0
webencodings==0.5.1

View file

@ -34,27 +34,40 @@
<h1 style="margin-bottom: 2rem;">Card Creator</h1>
<div style="display: flex; gap: 2rem; align-items: flex-start;">
<!-- Left: Inputs -->
<div style="flex: 1;">
<p>Use this tool to create custom Kemoverse cards! Fill in the details below, upload your art, and generate a card image.</p>
<p>Note: The card ID is generated based on the details, so make sure to fill them out!</p>
<div style="margin-bottom: 0.5rem;">
<input type="checkbox" id="restrictStatsToggle" checked>
<label for="restrictStatsToggle">Restrict stats by rarity</label>
</div>
<input type="text" id="nameInput" placeholder="Card Name" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="text" id="packInput" placeholder="Card Name" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="text" id="packInput" placeholder="Pack name" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="number" id="powerInput" placeholder="⚡ Power" min="0" max="999" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="number" id="charmInput" placeholder="❤️ Charm" min="0" max="999" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="number" id="witInput" placeholder="💫 Wit" min="0" max="999" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="number" id="packIdInput" placeholder="Pack Index" min="0" max="9999" style="width: 100%; margin-bottom: 0.5rem;"><br>
<textarea id="flavorInput" placeholder="Flavor text..." rows="4" style="width: 100%; margin-bottom: 0.5rem;"></textarea><br>
<input type="text" id="artistInput" placeholder="Artist Name" style="width: 100%; margin-bottom: 0.5rem;"><br>
<input type="file" id="uploadArt" style="margin-bottom: 0.5rem;"><br>
<select id="frameSelect" style="width: 100%; margin-bottom: 0.5rem;">
<option value="v1_common.png" selected>Common</option>
<option value="v1_uncommon.png">Uncommon</option>
<option value="v1_rare.png">Rare</option>
<option value="v1_epic.png">Epic</option>
<option value="v1_legendary.png">Legendary</option>
<option value="v1_common.png" selected>Common (100)</option>
<option value="v1_uncommon.png">Uncommon (140)</option>
<option value="v1_rare.png">Rare (180)</option>
<option value="v1_epic.png">Epic (220)</option>
<option value="v1_legendary.png">Legendary (250)</option>
</select>
<button id="downloadBtn" onclick="generateCard()">✨ Create Card</button>
<p>PI: Pack Index is used to identify the card within a pack.</p>
<p>SHA: Secure Hash Algorithm is used to generate a unique ID for the card based on its attributes.</p>
<p>All the processing is done in your browser, no data is sent to the server.</p>
</div>
<!-- Right: SVG Card Output -->
<div id="cardOutput" style="flex: 1; border: 1px solid #ccc; border-radius: 16px; background: #fff; box-shadow: 0 4px 10px rgba(0,0,0,0.1);">
@ -80,9 +93,19 @@ const artistInput = document.getElementById("artistInput");
const downloadBtn = document.getElementById("downloadBtn");
const uploadArt = document.getElementById("uploadArt");
const frameSelect = document.getElementById("frameSelect");
const restrictStatsToggle = document.getElementById("restrictStatsToggle");
let frameImage = new Image();
let uploadedImage = null;
// Rarity caps for stats
const rarityCaps = {
"v1_common.png": 100,
"v1_uncommon.png": 140,
"v1_rare.png": 180,
"v1_epic.png": 220,
"v1_legendary.png": 250
};
// Generate a unique ID for the card based on pack and card name
async function generateCardId(...args) {
const encoder = new TextEncoder();
@ -124,6 +147,10 @@ frameSelect.addEventListener("change", updateFrame);
function drawCard() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Place a white background
ctx.fillStyle = "#FFFFFF"; // white background
// make it smaller than the size of the canvas
ctx.fillRect(30, 30, canvas.width - 60, canvas.height - 60);
// Draw uploaded art as cropped and centered background if present
if (uploadedImage) {
@ -187,9 +214,9 @@ function drawCard() {
}
async function drawCardId() {
const id = await generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value);
const id = await generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value,packIdInput.value);
ctx.font = "15px sans-serif";
ctx.fillText("Kemoverse - " + id, canvas.width/2, canvas.height - 30);
ctx.fillText("Kemoverse - SHA: " + id + " - PI:" + packIdInput.value, canvas.width/2, canvas.height - 30);
}
@ -216,10 +243,54 @@ function wrapText(text, x, y, maxWidth, lineHeight) {
}
// Update card live when inputs change
[nameInput, powerInput, charmInput, witInput, flavorInput,packInput,artistInput].forEach(input => {
[nameInput, powerInput, charmInput, witInput, flavorInput, packInput, artistInput, packIdInput].forEach(input => {
input.addEventListener("input", drawCard);
});
// Enforce stat caps based on rarity and frame
function enforceStatCap(changedInput) {
if (!restrictStatsToggle.checked) return;
const cap = rarityCaps[frameSelect.value] || 9999;
let power = parseInt(powerInput.value) || 0;
let charm = parseInt(charmInput.value) || 0;
let wit = parseInt(witInput.value) || 0;
let total = power + charm + wit;
if (total > cap) {
// Reduce the changed input to fit the cap
const excess = total - cap;
if (changedInput === powerInput) {
power = Math.max(0, power - excess);
powerInput.value = power;
} else if (changedInput === charmInput) {
charm = Math.max(0, charm - excess);
charmInput.value = charm;
} else if (changedInput === witInput) {
wit = Math.max(0, wit - excess);
witInput.value = wit;
}
}
}
[powerInput, charmInput, witInput].forEach(input => {
input.addEventListener("input", function() {
enforceStatCap(this);
drawCard();
});
});
frameSelect.addEventListener("change", function() {
if (restrictStatsToggle.checked) {
enforceStatCap();
}
updateFrame();
});
restrictStatsToggle.addEventListener("change", function() {
if (this.checked) {
enforceStatCap();
}
});
// Download button
downloadBtn.addEventListener("click", () => {
// Sanitize file name
@ -240,13 +311,14 @@ jsonBtn.onclick = () => {
const cardData = {
name: nameInput.value,
pack: packInput.value,
pack_id: packIdInput.value,
power: powerInput.value,
charm: charmInput.value,
wit: witInput.value,
flavor: flavorInput.value,
artist: artistInput.value,
frame: frameSelect.value,
id: generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value)
id: generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value, packIdInput.value)
};
const safeName = (nameInput.value || "card").replace(/[^a-z0-9_\-]/gi, "_");
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
@ -269,6 +341,7 @@ zipBtn.onclick = async () => {
const cardData = {
name: nameInput.value,
pack: packInput.value,
pack_id: packIdInput.value,
power: powerInput.value,
charm: charmInput.value,
wit: witInput.value,
@ -281,6 +354,7 @@ zipBtn.onclick = async () => {
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
const imgFileName = `kemoverse_${safePack}_${safeName}.webp`;
const jsonFileName = `kemoverse_${safePack}_${safeName}.json`;
const zipFileName = `kemoverse_${safePack}_${safeName}.zip`;
// Create ZIP
const zip = new JSZip();
@ -294,7 +368,7 @@ zipBtn.onclick = async () => {
const content = await zip.generateAsync({type: "blob"});
const link = document.createElement("a");
link.href = URL.createObjectURL(content);
link.download = `kemoverse_${safePack}_${safeName}.zip`;
link.download = zipFileName;
link.click();
};