mirror of
https://github.com/Cametendo/devil-fruit-encyclopedia.git
synced 2026-09-13 15:59:48 +02:00
Add original generated portrait/icon artwork for every entry
Adds js/portraits.js: procedurally generated, original SVG artwork for all 101 characters (a role-themed line-art medallion — sword for swordsmen, crown for royalty, compass for navigators, etc., derived from each character's role/epithet/affiliation) and all 58 Devil Fruits (a stylized swirled-fruit icon colored by category with the fruit's symbolic emoji at center). Official One Piece artwork is copyrighted (Oda/Shueisha/Toei), so rather than scraping and re-hosting character art from the web, every image here is drawn from scratch with basic SVG primitives — no external assets, no copyright risk, fully offline-capable, and crisp at any size since it's vector. Wires the new generators into every card and detail view: character cards/profile header, Devil Fruit cards/detail header, the Devil-Fruit callout on character pages, and user chips on Devil Fruit pages. Removes the now-unused plain-letter initialFor() avatar helper.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
|
||||
<script src="js/characters-data.js"></script>
|
||||
<script src="js/fruits-data.js"></script>
|
||||
<script src="js/portraits.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/character.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -367,18 +367,18 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portrait-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.char-card__avatar {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
margin: 0 auto 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.1rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, var(--accent, var(--gold-deep)), color-mix(in srgb, var(--accent, var(--gold-deep)) 60%, black));
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--ink);
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25);
|
||||
}
|
||||
@@ -464,8 +464,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
.fruit-card__inner { padding: 1.1rem 1.15rem; }
|
||||
|
||||
.fruit-card__icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.7rem;
|
||||
box-shadow: 0 0 0 1px var(--surface-border);
|
||||
}
|
||||
|
||||
.fruit-card__inner h3 {
|
||||
@@ -536,13 +540,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
height: 110px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
|
||||
overflow: hidden;
|
||||
border: 4px solid var(--bg);
|
||||
box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm);
|
||||
}
|
||||
@@ -625,7 +623,14 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
|
||||
.fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); }
|
||||
|
||||
.fruit-callout__icon { font-size: 2.4rem; flex-shrink: 0; }
|
||||
.fruit-callout__icon {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--surface-border-strong);
|
||||
}
|
||||
|
||||
.fruit-callout__label {
|
||||
font-size: 0.72rem;
|
||||
@@ -708,13 +713,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -735,8 +734,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.fruit-detail-header__icon {
|
||||
font-size: 4rem;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 3px var(--cat-color, var(--gold)), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.fruit-detail-header h1 { margin-bottom: 0.2rem; }
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
<script src="js/characters-data.js"></script>
|
||||
<script src="js/fruits-data.js"></script>
|
||||
<script src="js/portraits.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/fruit.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
|
||||
<script src="js/characters-data.js"></script>
|
||||
<script src="js/fruits-data.js"></script>
|
||||
<script src="js/portraits.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/fruits.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
|
||||
<script src="js/characters-data.js"></script>
|
||||
<script src="js/fruits-data.js"></script>
|
||||
<script src="js/portraits.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -36,11 +36,6 @@ function accentFor(entity) {
|
||||
return `hsl(${hashHue(entity.name || key)}, 58%, 48%)`;
|
||||
}
|
||||
|
||||
function initialFor(name) {
|
||||
const match = (name || '?').match(/[A-Za-z]/);
|
||||
return match ? match[0].toUpperCase() : '?';
|
||||
}
|
||||
|
||||
function formatBounty(amount) {
|
||||
if (amount === null || amount === undefined) return 'Unknown';
|
||||
return '฿' + amount.toLocaleString('en-US');
|
||||
@@ -89,7 +84,7 @@ function characterCardHTML(c) {
|
||||
<div class="char-card__poster">
|
||||
${deadRibbon}
|
||||
<span class="char-card__wanted">WANTED</span>
|
||||
<div class="char-card__avatar">${initialFor(c.name)}</div>
|
||||
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
|
||||
</div>
|
||||
<div class="char-card__body">
|
||||
<h3>${escapeHtml(c.name)}</h3>
|
||||
@@ -112,7 +107,7 @@ function fruitCardHTML(f) {
|
||||
<article class="fruit-card" data-cat="${escapeHtml(f.category)}">
|
||||
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
|
||||
<div class="fruit-card__inner">
|
||||
<div class="fruit-card__icon">${f.icon}</div>
|
||||
<div class="fruit-card__icon">${fruitPortraitSVG(f)}</div>
|
||||
<h3>${escapeHtml(f.name)}</h3>
|
||||
<span class="fruit-card__type">${escapeHtml(f.category)}${f.subtype ? ' · ' + escapeHtml(f.subtype.split(' —')[0].split(' (')[0]) : ''}</span>
|
||||
<p class="fruit-card__tagline">${escapeHtml(f.tagline)}</p>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
root.innerHTML = `
|
||||
<section class="profile-header" style="--accent:${accent}">
|
||||
<div class="profile-avatar">${initialFor(character.name)}</div>
|
||||
<div class="profile-avatar">${characterPortraitSVG(character)}</div>
|
||||
<div>
|
||||
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
|
||||
<h1>${escapeHtml(character.name)}</h1>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="profile-main">
|
||||
${fruit ? `
|
||||
<a class="fruit-callout" href="${fruitLink(fruit.id)}">
|
||||
<div class="fruit-callout__icon">${fruit.icon}</div>
|
||||
<div class="fruit-callout__icon">${fruitPortraitSVG(fruit)}</div>
|
||||
<div>
|
||||
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
|
||||
<h3>${escapeHtml(fruit.name)}</h3>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
if (!c) return '';
|
||||
return `
|
||||
<a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}">
|
||||
<div class="user-chip__avatar">${initialFor(c.name)}</div>
|
||||
<div class="user-chip__avatar">${characterPortraitSVG(c)}</div>
|
||||
<div>
|
||||
<div class="user-chip__name">${escapeHtml(c.name)}</div>
|
||||
<div class="user-chip__status">${escapeHtml(u.status)}</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
root.innerHTML = `
|
||||
<section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}">
|
||||
<div class="fruit-detail-header__icon">${fruit.icon}</div>
|
||||
<div class="fruit-detail-header__icon">${fruitPortraitSVG(fruit)}</div>
|
||||
<div>
|
||||
<p class="profile-eyebrow" style="color:var(--cat-color)">${escapeHtml(fruit.category)}${fruit.subtype ? ' · ' + escapeHtml(fruit.subtype) : ''}${fruit.awakened ? ' · Awakened' : ''}</p>
|
||||
<h1>${escapeHtml(fruit.name)}</h1>
|
||||
|
||||
128
js/portraits.js
Normal file
128
js/portraits.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/* One Piece Wiki — original, procedurally generated portrait/icon artwork.
|
||||
Every character gets a role-themed line-art medallion; every Devil Fruit gets a
|
||||
stylized fruit-shape icon colored by type. All shapes are drawn from scratch here —
|
||||
no external art or copyrighted imagery is used anywhere in this file. */
|
||||
|
||||
function radialLines(cx, cy, rInner, rOuter, count, strokeWidth) {
|
||||
let out = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = (Math.PI * 2 * i) / count;
|
||||
const x1 = cx + rInner * Math.cos(a), y1 = cy + rInner * Math.sin(a);
|
||||
const x2 = cx + rOuter * Math.cos(a), y2 = cy + rOuter * Math.sin(a);
|
||||
out += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke-width="${strokeWidth}"/>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function starPoints(cx, cy, rOuter, rInner, count) {
|
||||
const pts = [];
|
||||
for (let i = 0; i < count * 2; i++) {
|
||||
const r = i % 2 === 0 ? rOuter : rInner;
|
||||
const a = (Math.PI * i) / count - Math.PI / 2;
|
||||
pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
/* Simple original line-art built from primitive shapes (circles/lines/polygons) — one per archetype. */
|
||||
const ROLE_ICONS = {
|
||||
sword: '<g transform="rotate(45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g><g transform="rotate(-45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g>',
|
||||
compass: `<circle cx="32" cy="32" r="18"/>${radialLines(32, 32, 15, 18, 12, 2)}<polygon points="32,21 37,32 32,43 27,32" fill="currentColor" stroke="none"/>`,
|
||||
target: '<circle cx="32" cy="32" r="18"/><circle cx="32" cy="32" r="11"/><circle cx="32" cy="32" r="4" fill="currentColor" stroke="none"/>',
|
||||
cross: '<line x1="32" y1="14" x2="32" y2="50"/><line x1="14" y1="32" x2="50" y2="32"/>',
|
||||
scroll: '<rect x="16" y="20" width="32" height="24" rx="2"/><circle cx="16" cy="20" r="4"/><circle cx="16" cy="44" r="4"/><circle cx="48" cy="20" r="4"/><circle cx="48" cy="44" r="4"/><line x1="22" y1="28" x2="42" y2="28"/><line x1="22" y1="36" x2="42" y2="36"/>',
|
||||
gear: `<circle cx="32" cy="32" r="9"/>${radialLines(32, 32, 12, 18, 8, 3)}`,
|
||||
note: '<circle cx="23" cy="46" r="6"/><circle cx="43" cy="42" r="6"/><line x1="29" y1="46" x2="29" y2="16"/><line x1="49" y1="42" x2="49" y2="14"/><line x1="29" y1="16" x2="49" y2="14"/><line x1="29" y1="24" x2="49" y2="22"/>',
|
||||
chefhat: '<path d="M20 44 h24 v6 h-24 z"/><path d="M22 44 C18 30 22 18 32 18 C34 12 44 12 46 18 C54 20 54 32 46 36 C46 40 46 42 44 44 h-22 C20 42 20 44 22 44 Z"/>',
|
||||
flask: '<line x1="26" y1="12" x2="26" y2="26"/><line x1="38" y1="12" x2="38" y2="26"/><line x1="24" y1="12" x2="40" y2="12"/><path d="M26 26 L16 48 a4 4 0 0 0 4 6 h24 a4 4 0 0 0 4 -6 L38 26 Z"/><line x1="20" y1="40" x2="44" y2="40"/>',
|
||||
anchor: '<circle cx="32" cy="16" r="5"/><line x1="32" y1="21" x2="32" y2="50"/><line x1="18" y1="30" x2="46" y2="30"/><path d="M18 38 a14 14 0 0 0 14 12"/><path d="M46 38 a14 14 0 0 1 -14 12"/>',
|
||||
crown: '<polygon points="16,44 16,26 24,34 32,20 40,34 48,26 48,44"/><line x1="16" y1="44" x2="48" y2="44"/>',
|
||||
tiara: '<path d="M16 40 Q32 16 48 40"/><circle cx="32" cy="22" r="4" fill="currentColor" stroke="none"/><line x1="16" y1="40" x2="48" y2="40"/>',
|
||||
flag: '<line x1="20" y1="12" x2="20" y2="52"/><path d="M20 14 h26 l-8 9 8 9 h-26 z"/>',
|
||||
dagger: '<line x1="32" y1="12" x2="32" y2="38"/><line x1="24" y1="20" x2="40" y2="20"/><polygon points="32,38 26,46 38,46"/>',
|
||||
skull: '<circle cx="32" cy="28" r="16"/><circle cx="25" cy="26" r="3" fill="currentColor" stroke="none"/><circle cx="39" cy="26" r="3" fill="currentColor" stroke="none"/><path d="M24 38 h16 M26 44 h4 M34 44 h4"/>',
|
||||
paw: '<circle cx="32" cy="38" r="10"/><circle cx="20" cy="24" r="5"/><circle cx="32" cy="18" r="5"/><circle cx="44" cy="24" r="5"/>',
|
||||
wing: '<path d="M14 40 C22 24 30 18 34 30 C38 18 46 24 50 40 C40 34 40 40 34 34 C28 40 24 34 14 40 Z"/>',
|
||||
star: `<polygon points="${starPoints(32, 32, 18, 8, 5)}"/>`,
|
||||
};
|
||||
|
||||
const ROLE_ICON_RULES = [
|
||||
[/navigat/, 'compass'],
|
||||
[/sniper|marksman|sharpshoot/, 'target'],
|
||||
[/doctor|medic|surgeon/, 'cross'],
|
||||
[/archaeolog|scholar/, 'scroll'],
|
||||
[/shipwright|engineer|mechanic/, 'gear'],
|
||||
[/musician|singer/, 'note'],
|
||||
[/cook|chef/, 'chefhat'],
|
||||
[/scientist/, 'flask'],
|
||||
[/helmsman|admiral|marine|navy|warden/, 'anchor'],
|
||||
[/king|emperor|empress|yonko|shogun/, 'crown'],
|
||||
[/princess|prince/, 'tiara'],
|
||||
[/revolutionary/, 'flag'],
|
||||
[/agent|spy|officer|assassin|cp0|cp9/, 'dagger'],
|
||||
[/mink|beast|tiger|lion|leopard|wolf|dragon/, 'paw'],
|
||||
[/phoenix|bird|wing|flight/, 'wing'],
|
||||
[/swordsm|santoryu|blade|commander|combatant/, 'sword'],
|
||||
[/captain|warlord|pirate/, 'skull'],
|
||||
];
|
||||
|
||||
function iconKeyForCharacter(c) {
|
||||
const haystack = `${c.role} ${c.epithet || ''} ${c.affiliation}`.toLowerCase();
|
||||
for (const [re, key] of ROLE_ICON_RULES) {
|
||||
if (re.test(haystack)) return key;
|
||||
}
|
||||
return 'star';
|
||||
}
|
||||
|
||||
function characterPortraitSVG(c) {
|
||||
const accent = accentFor(c);
|
||||
const hue2 = (hashHue(c.id + 'x') + 45) % 360;
|
||||
const icon = ROLE_ICONS[iconKeyForCharacter(c)] || ROLE_ICONS.star;
|
||||
const uid = c.id.replace(/[^a-z0-9]/g, '');
|
||||
const rotation = hashHue(c.name) % 90;
|
||||
const fruit = c.devilFruit ? findFruit(c.devilFruit) : null;
|
||||
|
||||
return `
|
||||
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(c.name)} portrait" class="portrait-svg">
|
||||
<defs>
|
||||
<linearGradient id="g-${uid}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${accent}"/>
|
||||
<stop offset="100%" stop-color="hsl(${hue2}, 55%, 26%)"/>
|
||||
</linearGradient>
|
||||
<pattern id="p-${uid}" width="9" height="9" patternTransform="rotate(${rotation})" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="0" y2="9" stroke="rgba(255,255,255,0.09)" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#g-${uid})"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#p-${uid})"/>
|
||||
<circle cx="32" cy="32" r="30.5" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
|
||||
<g fill="none" stroke="#fdf6e3" stroke-width="3.1" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">${icon}</g>
|
||||
${fruit ? `<circle cx="49" cy="49" r="11.5" fill="#0b1220" stroke="#fdf6e3" stroke-width="1.5"/><text x="49" y="53.5" font-size="12" text-anchor="middle">${fruit.icon}</text>` : ''}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const CATEGORY_HEX = { Paramecia: '#a06cd5', Zoan: '#4c9a5a', Logia: '#3f9bd0' };
|
||||
|
||||
function fruitPortraitSVG(f) {
|
||||
const color = CATEGORY_HEX[f.category] || '#e3b04b';
|
||||
const glow = f.subtype && /mythical|special/i.test(f.subtype);
|
||||
const uid = f.id.replace(/[^a-z0-9]/g, '');
|
||||
return `
|
||||
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(f.name)} icon" class="portrait-svg">
|
||||
<defs>
|
||||
<radialGradient id="fg-${uid}" cx="35%" cy="28%" r="80%">
|
||||
<stop offset="0%" stop-color="${color}" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="${color}" stop-opacity="0.28"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="34" r="32" fill="#0b1220"/>
|
||||
<path d="M32 14 C21 14 13 23 13 34 C13 47 21 54 32 54 C43 54 51 47 51 34 C51 23 43 14 32 14 Z"
|
||||
fill="url(#fg-${uid})" stroke="${glow ? '#e3b04b' : color}" stroke-width="2.2"/>
|
||||
<path d="M32 14 C30 9 31 5 36 3" fill="none" stroke="#7a9b57" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M20 27 C25 23 39 23 44 27" fill="none" stroke="rgba(255,255,255,0.32)" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M22 40 C27 44 37 44 42 40" fill="none" stroke="rgba(0,0,0,0.18)" stroke-width="2" stroke-linecap="round"/>
|
||||
<text x="32" y="41" font-size="19" text-anchor="middle">${f.icon}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user