Merge pull request 'More fixes and features to the card-creator' (#67) from card-creator into dev
Reviewed-on: #67
This commit is contained in:
commit
ef087e593f
3 changed files with 198 additions and 12 deletions
89
docs/design.md
Normal file
89
docs/design.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
@ -38,23 +38,36 @@
|
|||
<!-- 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();
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue