Compare commits
No commits in common. "ef087e593fb54e3478493cf98a2bb1a2e26767a2" and "86de43678c05c9543c3dc85218363098c64d930a" have entirely different histories.
ef087e593f
...
86de43678c
3 changed files with 12 additions and 198 deletions
|
@ -1,89 +0,0 @@
|
||||||
# 🎴 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,20 +15,8 @@
|
||||||
# along with this program. If not, see https://www.gnu.org/licenses/.
|
# along with this program. If not, see https://www.gnu.org/licenses/.
|
||||||
|
|
||||||
blinker==1.9.0
|
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
|
click==8.1.8
|
||||||
cssselect2==0.8.0
|
|
||||||
decorator==5.2.1
|
|
||||||
defusedxml==0.7.1
|
|
||||||
|
|
||||||
Flask==3.1.0
|
Flask==3.1.0
|
||||||
gunicorn==23.0.0
|
|
||||||
idna==3.10
|
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
|
@ -36,14 +24,3 @@ Werkzeug==3.1.3
|
||||||
Misskey.py==4.1.0
|
Misskey.py==4.1.0
|
||||||
Mastodon.py==1.8.1
|
Mastodon.py==1.8.1
|
||||||
filetype==1.2.0
|
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
|
|
||||||
|
|
|
@ -34,40 +34,27 @@
|
||||||
<h1 style="margin-bottom: 2rem;">Card Creator</h1>
|
<h1 style="margin-bottom: 2rem;">Card Creator</h1>
|
||||||
|
|
||||||
<div style="display: flex; gap: 2rem; align-items: flex-start;">
|
<div style="display: flex; gap: 2rem; align-items: flex-start;">
|
||||||
|
|
||||||
<!-- Left: Inputs -->
|
<!-- Left: Inputs -->
|
||||||
<div style="flex: 1;">
|
<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="nameInput" 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="text" id="packInput" placeholder="Card 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="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="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="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>
|
<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="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>
|
<input type="file" id="uploadArt" style="margin-bottom: 0.5rem;"><br>
|
||||||
<select id="frameSelect" style="width: 100%; margin-bottom: 0.5rem;">
|
<select id="frameSelect" style="width: 100%; margin-bottom: 0.5rem;">
|
||||||
<option value="v1_common.png" selected>Common (100)</option>
|
<option value="v1_common.png" selected>Common</option>
|
||||||
<option value="v1_uncommon.png">Uncommon (140)</option>
|
<option value="v1_uncommon.png">Uncommon</option>
|
||||||
<option value="v1_rare.png">Rare (180)</option>
|
<option value="v1_rare.png">Rare</option>
|
||||||
<option value="v1_epic.png">Epic (220)</option>
|
<option value="v1_epic.png">Epic</option>
|
||||||
<option value="v1_legendary.png">Legendary (250)</option>
|
<option value="v1_legendary.png">Legendary</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="downloadBtn" onclick="generateCard()">✨ Create Card</button>
|
<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>
|
</div>
|
||||||
<!-- Right: SVG Card Output -->
|
<!-- 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);">
|
<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);">
|
||||||
|
@ -93,19 +80,9 @@ const artistInput = document.getElementById("artistInput");
|
||||||
const downloadBtn = document.getElementById("downloadBtn");
|
const downloadBtn = document.getElementById("downloadBtn");
|
||||||
const uploadArt = document.getElementById("uploadArt");
|
const uploadArt = document.getElementById("uploadArt");
|
||||||
const frameSelect = document.getElementById("frameSelect");
|
const frameSelect = document.getElementById("frameSelect");
|
||||||
const restrictStatsToggle = document.getElementById("restrictStatsToggle");
|
|
||||||
let frameImage = new Image();
|
let frameImage = new Image();
|
||||||
let uploadedImage = null;
|
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
|
// Generate a unique ID for the card based on pack and card name
|
||||||
async function generateCardId(...args) {
|
async function generateCardId(...args) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
@ -147,10 +124,6 @@ frameSelect.addEventListener("change", updateFrame);
|
||||||
|
|
||||||
function drawCard() {
|
function drawCard() {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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
|
// Draw uploaded art as cropped and centered background if present
|
||||||
if (uploadedImage) {
|
if (uploadedImage) {
|
||||||
|
@ -214,9 +187,9 @@ function drawCard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawCardId() {
|
async function drawCardId() {
|
||||||
const id = await generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value,packIdInput.value);
|
const id = await generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value);
|
||||||
ctx.font = "15px sans-serif";
|
ctx.font = "15px sans-serif";
|
||||||
ctx.fillText("Kemoverse - SHA: " + id + " - PI:" + packIdInput.value, canvas.width/2, canvas.height - 30);
|
ctx.fillText("Kemoverse - " + id, canvas.width/2, canvas.height - 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -243,54 +216,10 @@ function wrapText(text, x, y, maxWidth, lineHeight) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update card live when inputs change
|
// Update card live when inputs change
|
||||||
[nameInput, powerInput, charmInput, witInput, flavorInput, packInput, artistInput, packIdInput].forEach(input => {
|
[nameInput, powerInput, charmInput, witInput, flavorInput,packInput,artistInput].forEach(input => {
|
||||||
input.addEventListener("input", drawCard);
|
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
|
// Download button
|
||||||
downloadBtn.addEventListener("click", () => {
|
downloadBtn.addEventListener("click", () => {
|
||||||
// Sanitize file name
|
// Sanitize file name
|
||||||
|
@ -311,14 +240,13 @@ jsonBtn.onclick = () => {
|
||||||
const cardData = {
|
const cardData = {
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
pack: packInput.value,
|
pack: packInput.value,
|
||||||
pack_id: packIdInput.value,
|
|
||||||
power: powerInput.value,
|
power: powerInput.value,
|
||||||
charm: charmInput.value,
|
charm: charmInput.value,
|
||||||
wit: witInput.value,
|
wit: witInput.value,
|
||||||
flavor: flavorInput.value,
|
flavor: flavorInput.value,
|
||||||
artist: artistInput.value,
|
artist: artistInput.value,
|
||||||
frame: frameSelect.value,
|
frame: frameSelect.value,
|
||||||
id: generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value, packIdInput.value)
|
id: generateCardId(packInput.value, nameInput.value, powerInput.value, charmInput.value, witInput.value, artistInput.value)
|
||||||
};
|
};
|
||||||
const safeName = (nameInput.value || "card").replace(/[^a-z0-9_\-]/gi, "_");
|
const safeName = (nameInput.value || "card").replace(/[^a-z0-9_\-]/gi, "_");
|
||||||
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
|
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
|
||||||
|
@ -341,7 +269,6 @@ zipBtn.onclick = async () => {
|
||||||
const cardData = {
|
const cardData = {
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
pack: packInput.value,
|
pack: packInput.value,
|
||||||
pack_id: packIdInput.value,
|
|
||||||
power: powerInput.value,
|
power: powerInput.value,
|
||||||
charm: charmInput.value,
|
charm: charmInput.value,
|
||||||
wit: witInput.value,
|
wit: witInput.value,
|
||||||
|
@ -354,7 +281,6 @@ zipBtn.onclick = async () => {
|
||||||
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
|
const safePack = (packInput.value || "pack").replace(/[^a-z0-9_\-]/gi, "_");
|
||||||
const imgFileName = `kemoverse_${safePack}_${safeName}.webp`;
|
const imgFileName = `kemoverse_${safePack}_${safeName}.webp`;
|
||||||
const jsonFileName = `kemoverse_${safePack}_${safeName}.json`;
|
const jsonFileName = `kemoverse_${safePack}_${safeName}.json`;
|
||||||
const zipFileName = `kemoverse_${safePack}_${safeName}.zip`;
|
|
||||||
|
|
||||||
// Create ZIP
|
// Create ZIP
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
@ -368,7 +294,7 @@ zipBtn.onclick = async () => {
|
||||||
const content = await zip.generateAsync({type: "blob"});
|
const content = await zip.generateAsync({type: "blob"});
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = URL.createObjectURL(content);
|
link.href = URL.createObjectURL(content);
|
||||||
link.download = zipFileName;
|
link.download = `kemoverse_${safePack}_${safeName}.zip`;
|
||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue