feat: improve character customization colors

This commit is contained in:
jwinterm
2026-07-05 21:09:52 -04:00
parent 454e6ab69a
commit 07cfe80591
9 changed files with 477 additions and 142 deletions
+2 -2
View File
@@ -108,7 +108,7 @@
<button id="watchGamesButton" style="background: #053655; color: #0af; border: 2px solid #0af; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
👁️ <span class="lbl-full">WATCH GAMES</span><span class="lbl-short">WATCH</span>
</button>
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern.html'">
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern'">
🍺 <span class="lbl-full">TAVERN</span><span class="lbl-short">TAVERN</span>
</button>
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
@@ -386,4 +386,4 @@
<button id="dpad-down" type="button" aria-label="Move down">▼</button>
</div>
</body>
</html>
</html>
+12 -1
View File
@@ -7,6 +7,7 @@
RK.TILE_KINDS = ['floor', 'wall', 'bar', 'table'];
var FREE_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
var FREE_COLORS = { base: 'none', skin: 'natural', hair: 'copper', body: 'none', head: 'none', shield: 'none', weapon: 'none' };
RK.PACKS = {
'roguelike-interior': {
@@ -173,6 +174,14 @@
return out;
}
function normalizeColors(colors, tint) {
if (RK.normalizeCharColors) return RK.normalizeCharColors(colors, tint);
colors = colors || {};
var out = {};
for (var k in FREE_COLORS) out[k] = colors[k] || (k === 'base' ? (tint || FREE_COLORS[k]) : FREE_COLORS[k]);
return out;
}
RK.packAppearances = function () {
var out = [];
for (var id in RK.EXTRA_APPEARANCES) out.push(RK.EXTRA_APPEARANCES[id]);
@@ -237,6 +246,7 @@
avatar: avatar,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint),
character: RK.isoAssets.character
};
}
@@ -247,7 +257,8 @@
avatar: avatar,
model: RK.threeAssets.models[avatar] || RK.threeAssets.models.fallback,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment)
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint)
};
}
return { projection: 'topdown', avatar: avatar, appearance: appearance };
+282 -97
View File
@@ -6,6 +6,7 @@
var RK = root.RK = root.RK || {};
var DEFAULT_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
var DEFAULT_COLORS = { base: 'none', skin: 'natural', hair: 'copper', body: 'none', head: 'none', shield: 'none', weapon: 'none' };
function cloneEquipment(eq) {
eq = eq || {};
@@ -15,6 +16,14 @@
return out;
}
function cloneColors(colors, legacyTint) {
if (RK.normalizeCharColors) return RK.normalizeCharColors(colors, legacyTint);
colors = colors || {};
var out = {};
for (var k in DEFAULT_COLORS) out[k] = colors[k] || (k === 'base' ? (legacyTint || DEFAULT_COLORS[k]) : DEFAULT_COLORS[k]);
return out;
}
function baseAppearances() {
var list = [
{ id: 'default', label: 'Operator', kind: 'color', color: '#9aa4b2' },
@@ -56,19 +65,23 @@
var isChar = !!(RK.isChar && RK.isChar(avatar));
var tint = 'none';
var equipment = cloneEquipment(DEFAULT_EQUIPMENT);
var colors = null;
if (isChar) {
if (RK.CHAR_TINTS && RK.CHAR_TINTS[input.tint]) tint = input.tint;
colors = cloneColors(input.colors, input.tint);
tint = colors.base;
equipment = RK.normalizeCharEquipment ? RK.normalizeCharEquipment(input.equipment) : cloneEquipment(input.equipment);
}
return { avatar: avatar, tint: tint, equipment: equipment };
var out = { avatar: avatar, tint: tint, equipment: equipment };
if (isChar) out.colors = colors;
return out;
};
RK.appearance = function (input) {
var ap = RK.normalizeAppearance(input);
var base = baseAppearance(ap.avatar);
var label = base.label || base.id;
if (base.kind === 'char' && ap.tint && ap.tint !== 'none' && RK.CHAR_TINTS && RK.CHAR_TINTS[ap.tint]) {
label += ' / ' + RK.CHAR_TINTS[ap.tint].label;
if (base.kind === 'char' && ap.colors && ap.colors.base && ap.colors.base !== 'none' && RK.CHAR_TINTS && RK.CHAR_TINTS[ap.colors.base]) {
label += ' / ' + RK.CHAR_TINTS[ap.colors.base].label;
}
return {
id: ap.avatar,
@@ -108,8 +121,11 @@
ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.fillText('...', 36, 44);
return;
}
var ap = RK.normalizeAppearance(draft || { avatar: a.id });
ap.avatar = a.id;
var source = {};
var d = draft || {};
for (var dk in d) source[dk] = d[dk];
source.avatar = a.id;
var ap = RK.normalizeAppearance(source);
if (!RK.drawCharCompositeCanvas(ctx, ap, 1, 8, 70)) {
RK.loadCharAtlas(onReady);
ctx.fillStyle = '#666'; ctx.fillText('...', 36, 44);
@@ -161,8 +177,9 @@
ctx.beginPath(); ctx.moveTo(17, 26); ctx.lineTo(35, 26); ctx.stroke();
return;
}
var tint = item.tint === false ? 'none' : draft.tint;
if (!RK.drawCharTileCanvas || !RK.drawCharTileCanvas(ctx, item.frame, tint, 2, 2, 48, 48)) {
var tint = item.tint === false ? 'none' : ((draft.colors && draft.colors[slot]) || draft.tint);
var colors = item.tint === false ? null : draft.colors;
if (!RK.drawCharTileCanvas || !RK.drawCharTileCanvas(ctx, item.frame, tint, 2, 2, 48, 48, colors, slot)) {
RK.loadCharAtlas(draw);
ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.fillText('...', 26, 30);
}
@@ -172,132 +189,307 @@
return cv;
}
function ensureCustomizeStyle() {
if (document.getElementById('rk-customize-style')) return;
var style = document.createElement('style');
style.id = 'rk-customize-style';
style.textContent = [
'.rk-customize{position:fixed;inset:0;z-index:9999;background:rgba(4,6,8,.94);color:#d7dbe0;font-family:ui-monospace,monospace;overflow:hidden;}',
'.rk-customize *{box-sizing:border-box;letter-spacing:0;}',
'.rk-customize-shell{height:100%;max-width:1180px;margin:0 auto;padding:16px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:14px;}',
'.rk-customize-top{display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid #202832;padding-bottom:12px;}',
'.rk-customize-title{font-size:16px;font-weight:700;color:#f0f3f6;overflow-wrap:anywhere;}',
'.rk-customize-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;}',
'.rk-customize-body{min-height:0;display:grid;grid-template-columns:190px minmax(0,1fr);gap:16px;}',
'.rk-customize-preview{align-self:start;position:sticky;top:0;border:1px solid #26303b;background:#0b0f14;border-radius:8px;padding:12px;}',
'.rk-customize-preview canvas{display:block;width:100%;height:auto;image-rendering:pixelated;background:#090c10;border:1px solid #2a313a;border-radius:6px;}',
'.rk-customize-name{font-size:13px;font-weight:700;color:#f0f3f6;margin-top:10px;overflow-wrap:anywhere;}',
'.rk-customize-meta{font-size:11px;color:#8b949e;margin-top:4px;overflow-wrap:anywhere;}',
'.rk-customize-content{min-width:0;overflow:auto;padding-right:4px;}',
'.rk-customize-section{margin-bottom:18px;}',
'.rk-customize-section h3{margin:0 0 8px;font-size:12px;line-height:1.2;color:#9aa4b2;font-weight:700;text-transform:uppercase;}',
'.rk-avatar-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px;}',
'.rk-option-card{min-width:0;min-height:112px;border:2px solid #2a313a;border-radius:8px;background:#11161c;color:#c7ccd4;cursor:pointer;padding:7px;font:inherit;font-size:11px;text-align:center;}',
'.rk-option-card:hover{border-color:#566274;background:#151b22;}',
'.rk-option-card.is-selected{border-color:#3fb950;box-shadow:0 0 0 1px rgba(63,185,80,.3) inset;}',
'.rk-option-card.is-locked{opacity:.48;cursor:not-allowed;}',
'.rk-option-card canvas{max-width:72px;width:100%;height:auto;}',
'.rk-option-label{margin-top:5px;line-height:1.2;overflow-wrap:anywhere;color:#d0d6de;}',
'.rk-customize-row{display:grid;grid-template-columns:86px minmax(0,1fr);gap:10px;align-items:start;margin-bottom:10px;}',
'.rk-row-label{font-size:12px;color:#9aa4b2;padding-top:9px;overflow-wrap:anywhere;}',
'.rk-row-options{display:flex;gap:7px;flex-wrap:wrap;min-width:0;}',
'.rk-swatch,.rk-equip-option{border:2px solid #2a313a;border-radius:8px;background:#11161c;color:#c7ccd4;cursor:pointer;font:inherit;font-size:11px;}',
'.rk-swatch{width:70px;min-height:58px;padding:6px;}',
'.rk-equip-option{width:78px;min-height:76px;padding:5px;}',
'.rk-swatch:hover,.rk-equip-option:hover{border-color:#566274;background:#151b22;}',
'.rk-swatch.is-selected,.rk-equip-option.is-selected{border-color:#3fb950;box-shadow:0 0 0 1px rgba(63,185,80,.3) inset;}',
'.rk-swatch-dot{display:block;width:24px;height:24px;margin:0 auto 5px;border-radius:50%;border:1px solid rgba(255,255,255,.35);}',
'.rk-none-dot{background:linear-gradient(135deg,#171c22 0,#171c22 45%,#9aa4b2 47%,#9aa4b2 53%,#171c22 55%,#171c22 100%);}',
'.rk-button{font:inherit;border-radius:8px;padding:8px 14px;cursor:pointer;font-weight:700;}',
'.rk-save{border:0;background:#3fb950;color:#071109;}',
'.rk-cancel{border:1px solid #2a313a;background:#10151b;color:#d7dbe0;}',
'@media (max-width:760px){.rk-customize-shell{padding:10px;}.rk-customize-top{align-items:flex-start;}.rk-customize-body{grid-template-columns:1fr;overflow:auto;}.rk-customize-content{overflow:visible;padding-right:0;}.rk-customize-preview{position:relative;display:grid;grid-template-columns:96px minmax(0,1fr);gap:10px;align-items:center;}.rk-customize-preview canvas{width:96px;}.rk-customize-row{grid-template-columns:1fr;gap:6px;}.rk-row-label{padding-top:0;}.rk-avatar-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));}.rk-option-card{min-height:104px;}.rk-customize-actions{width:100%;justify-content:flex-start;}}'
].join('');
document.head.appendChild(style);
}
RK.openCustomize = function (current, onSave) {
ensureCustomizeStyle();
var draft = RK.normalizeAppearance(current);
var redrawers = [];
var cards = {};
function isCharDraft() {
return !!(RK.isChar && RK.isChar(draft.avatar));
}
function ensureCharDraft() {
if (!draft.colors) draft.colors = cloneColors(null, draft.tint);
if (!draft.equipment) draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
draft.tint = draft.colors.base;
}
var wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:9999;overflow:auto;padding:20px;font-family:ui-monospace,monospace;color:#d7dbe0;';
wrap.className = 'rk-customize';
var h = document.createElement('div');
h.textContent = 'Customize your character';
h.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:12px;';
wrap.appendChild(h);
var shell = document.createElement('div');
shell.className = 'rk-customize-shell';
wrap.appendChild(shell);
var top = document.createElement('div');
top.className = 'rk-customize-top';
shell.appendChild(top);
var title = document.createElement('div');
title.className = 'rk-customize-title';
title.textContent = 'Customize your character';
top.appendChild(title);
var actions = document.createElement('div');
actions.className = 'rk-customize-actions';
top.appendChild(actions);
var save = document.createElement('button');
save.type = 'button';
save.className = 'rk-button rk-save';
save.textContent = 'Save';
actions.appendChild(save);
var cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'rk-button rk-cancel';
cancel.textContent = 'Cancel';
actions.appendChild(cancel);
var body = document.createElement('div');
body.className = 'rk-customize-body';
shell.appendChild(body);
var previewPane = document.createElement('div');
previewPane.className = 'rk-customize-preview';
body.appendChild(previewPane);
var preview = document.createElement('canvas');
preview.width = 92; preview.height = 100;
preview.style.cssText = 'display:block;margin:0 0 14px;image-rendering:pixelated;background:#0a0c0f;border:1px solid #2a313a;border-radius:6px;';
preview.width = 150;
preview.height = 164;
var pctx = preview.getContext('2d');
wrap.appendChild(preview);
previewPane.appendChild(preview);
var previewText = document.createElement('div');
previewPane.appendChild(previewText);
var previewName = document.createElement('div');
previewName.className = 'rk-customize-name';
previewText.appendChild(previewName);
var previewMeta = document.createElement('div');
previewMeta.className = 'rk-customize-meta';
previewText.appendChild(previewMeta);
var content = document.createElement('div');
content.className = 'rk-customize-content';
body.appendChild(content);
var avatarSection = document.createElement('section');
avatarSection.className = 'rk-customize-section';
content.appendChild(avatarSection);
var avatarTitle = document.createElement('h3');
avatarTitle.textContent = 'Avatar';
avatarSection.appendChild(avatarTitle);
var grid = document.createElement('div');
grid.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;max-width:820px;';
var cards = {};
RK.appearances().forEach(function (a) {
var card = document.createElement('div');
var locked = !!(RK.canUseAppearance && !RK.canUseAppearance(a));
card.style.cssText = 'width:108px;padding:8px;border:2px solid #2a313a;border-radius:8px;background:#0f1216;cursor:pointer;text-align:center;';
if (locked) card.style.cssText += 'opacity:0.55;';
if (locked) card.title = 'Premium pack - buy credits to unlock';
card.appendChild(thumbCanvas(a, function () { return draft; }, redrawers));
var lab = document.createElement('div');
lab.textContent = a.label + (a.premium ? ' *' : '');
lab.style.cssText = 'font-size:11px;margin-top:6px;color:#c7ccd4;';
card.appendChild(lab);
card.onclick = function () {
if (RK.canUseAppearance && !RK.canUseAppearance(a)) return;
draft.avatar = a.id;
if (!(RK.isChar && RK.isChar(draft.avatar))) {
draft.tint = 'none';
draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
}
mark();
renderRows();
redrawAll();
};
grid.appendChild(card);
cards[a.id] = card;
});
wrap.appendChild(grid);
grid.className = 'rk-avatar-grid';
avatarSection.appendChild(grid);
var rows = document.createElement('div');
rows.style.cssText = 'max-width:820px;margin-top:14px;';
wrap.appendChild(rows);
var editSection = document.createElement('section');
editSection.className = 'rk-customize-section';
content.appendChild(editSection);
function colorCatalog(kind) {
if (kind === 'skin') return RK.CHAR_SKIN_TONES || {};
if (kind === 'hair') return RK.CHAR_HAIR_COLORS || {};
return RK.CHAR_TINTS || {};
}
function sectionTitle(text, parent) {
var h = document.createElement('h3');
h.textContent = text;
parent.appendChild(h);
return h;
}
function row(labelText, parent) {
var r = document.createElement('div');
r.className = 'rk-customize-row';
var label = document.createElement('div');
label.className = 'rk-row-label';
label.textContent = labelText;
r.appendChild(label);
var opts = document.createElement('div');
opts.className = 'rk-row-options';
r.appendChild(opts);
parent.appendChild(r);
return opts;
}
function optionButton(label, selected, onclick, canvas) {
var b = document.createElement('button');
b.type = 'button';
b.style.cssText = 'width:86px;min-height:78px;border:2px solid ' + (selected ? '#3fb950' : '#2a313a') + ';border-radius:6px;background:#0f1216;color:#c7ccd4;cursor:pointer;padding:6px;font:inherit;font-size:11px;';
b.className = 'rk-equip-option' + (selected ? ' is-selected' : '');
if (canvas) b.appendChild(canvas);
var t = document.createElement('div');
t.className = 'rk-option-label';
t.textContent = label;
b.appendChild(t);
b.onclick = onclick;
return b;
}
function renderRows() {
rows.innerHTML = '';
if (!(RK.isChar && RK.isChar(draft.avatar))) return;
function swatchButton(item, selected, onclick) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'rk-swatch' + (selected ? ' is-selected' : '');
var dot = document.createElement('span');
dot.className = 'rk-swatch-dot' + (!item.color ? ' rk-none-dot' : '');
if (item.color) dot.style.background = item.color;
b.appendChild(dot);
var text = document.createElement('div');
text.className = 'rk-option-label';
text.textContent = item.label || item.id;
b.appendChild(text);
b.onclick = onclick;
return b;
}
var tintRow = document.createElement('div');
tintRow.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;';
var tintLabel = document.createElement('div');
tintLabel.textContent = 'Tint';
tintLabel.style.cssText = 'width:68px;font-size:12px;color:#8b949e;';
tintRow.appendChild(tintLabel);
for (var tid in (RK.CHAR_TINTS || {})) {
(function (tint) {
var sw = document.createElement('span');
sw.style.cssText = 'display:block;width:28px;height:28px;margin:2px auto 5px;border-radius:50%;background:' + (tint.color || '#9aa4b2') + ';border:1px solid rgba(255,255,255,0.35);';
var b = optionButton(tint.label, draft.tint === tint.id, function () {
draft.tint = tint.id;
renderRows();
redrawAll();
}, sw);
b.style.minHeight = '64px';
tintRow.appendChild(b);
})(RK.CHAR_TINTS[tid]);
function setColor(slot, id) {
ensureCharDraft();
draft.colors[slot] = id;
if (slot === 'base') draft.tint = id;
renderEditor();
redrawAll();
}
function renderColorRow(label, slot, catalogKind, parent) {
var opts = row(label, parent);
var catalog = colorCatalog(catalogKind);
for (var id in catalog) {
(function (item) {
opts.appendChild(swatchButton(item, draft.colors && draft.colors[slot] === item.id, function () {
setColor(slot, item.id);
}));
})(catalog[id]);
}
rows.appendChild(tintRow);
}
function renderEditor() {
editSection.innerHTML = '';
if (!isCharDraft()) return;
ensureCharDraft();
sectionTitle('Color', editSection);
renderColorRow('Cloth', 'base', 'tint', editSection);
renderColorRow('Skin', 'skin', 'skin', editSection);
renderColorRow('Hair', 'hair', 'hair', editSection);
sectionTitle('Equipment', editSection);
(RK.CHAR_EQUIPMENT_SLOTS || []).forEach(function (slot) {
var catalog = RK.CHAR_EQUIPMENT[slot] || {};
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;';
var label = document.createElement('div');
label.textContent = slot.charAt(0).toUpperCase() + slot.slice(1);
label.style.cssText = 'width:68px;font-size:12px;color:#8b949e;';
row.appendChild(label);
var opts = row(slot.charAt(0).toUpperCase() + slot.slice(1), editSection);
for (var id in catalog) {
(function (item) {
row.appendChild(optionButton(item.label, draft.equipment[slot] === item.id, function () {
opts.appendChild(optionButton(item.label, draft.equipment[slot] === item.id, function () {
draft.equipment[slot] = item.id;
renderRows();
renderEditor();
redrawAll();
}, optionTileCanvas(slot, item, draft, redrawers)));
}, optionTileCanvas(slot, item, draft, null)));
})(catalog[id]);
}
rows.appendChild(row);
renderColorRow(slot.charAt(0).toUpperCase() + slot.slice(1) + ' color', slot, 'tint', editSection);
});
}
function selectAvatar(a) {
if (RK.canUseAppearance && !RK.canUseAppearance(a)) return;
draft.avatar = a.id;
if (RK.isChar && RK.isChar(draft.avatar)) {
ensureCharDraft();
} else {
draft.tint = 'none';
draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
delete draft.colors;
}
mark();
renderEditor();
redrawAll();
}
RK.appearances().forEach(function (a) {
var card = document.createElement('button');
var locked = !!(RK.canUseAppearance && !RK.canUseAppearance(a));
card.type = 'button';
card.className = 'rk-option-card' + (locked ? ' is-locked' : '');
if (locked) card.title = 'Premium pack - buy credits to unlock';
card.appendChild(thumbCanvas(a, function () { return draft; }, redrawers));
var lab = document.createElement('div');
lab.className = 'rk-option-label';
lab.textContent = a.label + (a.premium ? ' *' : '');
card.appendChild(lab);
card.onclick = function () { selectAvatar(a); };
grid.appendChild(card);
cards[a.id] = card;
});
function drawPreview() {
pctx.clearRect(0, 0, preview.width, preview.height);
var base = baseAppearance(draft.avatar);
if (base.kind === 'color') {
pctx.beginPath(); pctx.arc(46, 52, 24, 0, Math.PI * 2);
pctx.beginPath(); pctx.arc(75, 86, 34, 0, Math.PI * 2);
pctx.fillStyle = base.color; pctx.fill();
pctx.lineWidth = 2; pctx.strokeStyle = 'rgba(255,255,255,0.35)'; pctx.stroke();
pctx.lineWidth = 3; pctx.strokeStyle = 'rgba(255,255,255,0.35)'; pctx.stroke();
} else if (base.kind === 'char') {
if (!RK.drawCharCompositeCanvas || !RK.drawCharCompositeCanvas(pctx, draft, 6, 8, 80)) RK.loadCharAtlas(redrawAll);
if (!RK.drawCharCompositeCanvas || !RK.drawCharCompositeCanvas(pctx, draft, 15, 12, 120)) RK.loadCharAtlas(redrawAll);
} else if (base.kind === 'model3d') {
pctx.beginPath(); pctx.arc(46, 52, 24, 0, Math.PI * 2);
pctx.beginPath(); pctx.arc(75, 86, 34, 0, Math.PI * 2);
pctx.fillStyle = '#64748b'; pctx.fill();
pctx.fillStyle = '#d7dbe0'; pctx.font = '20px monospace'; pctx.textAlign = 'center'; pctx.fillText('3D', 46, 59);
pctx.fillStyle = '#d7dbe0'; pctx.font = '26px monospace'; pctx.textAlign = 'center'; pctx.fillText('3D', 75, 95);
} else if (base.kind === 'skin') {
var rec = RK.skinSheet(base.id);
if (!rec) {
var r = RK.loadSkin(base.id);
if (r && r.cbs) r.cbs.push(redrawAll);
pctx.fillStyle = '#666'; pctx.font = '13px monospace'; pctx.textAlign = 'center'; pctx.fillText('...', 75, 88);
} else {
var s = RK.SKINS[base.id];
var row = (s.dirRows && s.dirRows.down) || 0;
var dh = 142, dw = dh * (s.frameW / s.frameH);
pctx.imageSmoothingEnabled = true;
pctx.drawImage(rec.img, 0, row * s.frameH, s.frameW, s.frameH, 75 - dw / 2, 158 - dh, dw, dh);
}
} else {
drawBaseThumb(pctx, base, draft, redrawAll);
}
previewName.textContent = base.label || base.id;
previewMeta.textContent = base.premium ? 'Premium pack' : (base.kind === 'char' ? 'Customizable' : 'Free');
}
function redrawAll() {
@@ -306,30 +498,23 @@
}
function mark() {
for (var id in cards) cards[id].style.borderColor = (id === draft.avatar) ? '#3fb950' : '#2a313a';
for (var id in cards) {
if (id === draft.avatar) cards[id].classList.add('is-selected');
else cards[id].classList.remove('is-selected');
}
}
var bar = document.createElement('div');
bar.style.cssText = 'margin-top:16px;display:flex;gap:8px;';
var save = document.createElement('button');
save.textContent = 'Save';
save.style.cssText = 'font:inherit;padding:8px 16px;border:0;border-radius:6px;background:#3fb950;color:#08130a;font-weight:600;cursor:pointer;';
save.onclick = function () {
var saved = RK.normalizeAppearance(draft);
RK.saveIdentity(saved);
document.body.removeChild(wrap);
if (onSave) onSave(saved);
};
var cancel = document.createElement('button');
cancel.textContent = 'Cancel';
cancel.style.cssText = 'font:inherit;padding:8px 16px;border:1px solid #2a313a;border-radius:6px;background:#0f1216;color:#d7dbe0;cursor:pointer;';
cancel.onclick = function () { document.body.removeChild(wrap); };
bar.appendChild(save); bar.appendChild(cancel);
wrap.appendChild(bar);
document.body.appendChild(wrap);
mark();
renderRows();
renderEditor();
redrawAll();
};
})(window);
+109 -26
View File
@@ -17,6 +17,22 @@
'213,201,228': 3,
'255,153,204': 2 // hot-pink accent used by a few weapon overlays
};
var SKIN_SOURCE = {
'156,118,80': 0,
'161,125,82': 0,
'170,143,111': 1,
'176,128,82': 1,
'200,164,128': 2,
'229,196,157': 3
};
var HAIR_SOURCE = {
'114,88,57': 0,
'149,92,24': 1,
'163,84,34': 1,
'177,92,35': 2,
'192,117,28': 2,
'198,101,39': 3
};
RK.CHAR_TINTS = {
none: { id: 'none', label: 'Natural', color: null, ramp: null },
@@ -27,6 +43,32 @@
violet: { id: 'violet', label: 'Violet', color: '#a084c4', ramp: ['#74558f', '#8f6eb0', '#a084c4', '#d5c9e4'] },
ash: { id: 'ash', label: 'Ash', color: '#878787', ramp: ['#4f4f4f', '#646464', '#878787', '#cacaca'] }
};
RK.CHAR_SKIN_TONES = {
natural: { id: 'natural', label: 'Natural', color: '#e5c49d', ramp: ['#9c7650', '#aa8f6f', '#c8a480', '#e5c49d'] },
fair: { id: 'fair', label: 'Fair', color: '#f2d6b3', ramp: ['#b38361', '#c9a07a', '#e2be94', '#f2d6b3'] },
warm: { id: 'warm', label: 'Warm', color: '#c78355', ramp: ['#704226', '#9a5d37', '#c78355', '#e0a777'] },
umber: { id: 'umber', label: 'Umber', color: '#8b5a3c', ramp: ['#3e291d', '#5b3a28', '#8b5a3c', '#b7825c'] },
olive: { id: 'olive', label: 'Olive', color: '#9f9a64', ramp: ['#5b5a38', '#777246', '#9f9a64', '#c2bb83'] },
ash: { id: 'ash', label: 'Ash', color: '#a7a7a7', ramp: ['#585858', '#767676', '#a7a7a7', '#d1d1d1'] }
};
RK.CHAR_HAIR_COLORS = {
copper: { id: 'copper', label: 'Copper', color: '#c66527', ramp: ['#724627', '#a35424', '#c66527', '#d8893d'] },
brown: { id: 'brown', label: 'Brown', color: '#7b5437', ramp: ['#34261b', '#573a28', '#7b5437', '#a47a53'] },
black: { id: 'black', label: 'Black', color: '#333333', ramp: ['#111111', '#202020', '#333333', '#555555'] },
blond: { id: 'blond', label: 'Blond', color: '#d7b45a', ramp: ['#80662d', '#a9863a', '#d7b45a', '#f0d98a'] },
silver: { id: 'silver', label: 'Silver', color: '#c8c8c8', ramp: ['#686868', '#929292', '#c8c8c8', '#ececec'] },
violet: { id: 'violet', label: 'Violet', color: '#9f7aea', ramp: ['#46315f', '#694c93', '#9f7aea', '#d8c4ff'] }
};
var DEFAULT_COLORS = {
base: 'none',
skin: 'natural',
hair: 'copper',
body: 'none',
head: 'none',
shield: 'none',
weapon: 'none'
};
RK.CHAR_DEFAULT_COLORS = DEFAULT_COLORS;
RK.CHAR_EQUIPMENT = {
body: {
@@ -78,6 +120,27 @@
function validTint(id) {
return (id && RK.CHAR_TINTS[id]) ? id : 'none';
}
function validSkin(id) {
return (id && RK.CHAR_SKIN_TONES[id]) ? id : 'natural';
}
function validHair(id) {
return (id && RK.CHAR_HAIR_COLORS[id]) ? id : 'copper';
}
RK.normalizeCharColors = function (colors, legacyTint) {
var input = colors || {};
function tintFor(slot) {
return validTint(Object.prototype.hasOwnProperty.call(input, slot) ? input[slot] : legacyTint);
}
return {
base: tintFor('base'),
skin: validSkin(input.skin),
hair: validHair(input.hair),
body: tintFor('body'),
head: tintFor('head'),
shield: tintFor('shield'),
weapon: tintFor('weapon')
};
};
function validEquipment(slot, id) {
var catalog = RK.CHAR_EQUIPMENT[slot] || {};
@@ -97,10 +160,12 @@
e = e || {};
var ap = e.appearance || {};
var avatar = e.avatar || ap.avatar || 'char-villager';
var colors = RK.normalizeCharColors(ap.colors, ap.tint);
return {
avatar: avatar,
tint: validTint(ap.tint),
equipment: RK.normalizeCharEquipment(ap.equipment)
tint: colors.base,
equipment: RK.normalizeCharEquipment(ap.equipment),
colors: colors
};
};
@@ -108,6 +173,8 @@
var ap = RK.charAppearance(e);
return [
ap.avatar, ap.tint,
ap.colors.base, ap.colors.skin, ap.colors.hair,
ap.colors.body, ap.colors.head, ap.colors.shield, ap.colors.weapon,
ap.equipment.body, ap.equipment.head, ap.equipment.shield, ap.equipment.weapon
].join('|');
};
@@ -147,13 +214,30 @@
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function tintedTileCanvas(idx, tintId) {
function applyRamp(img, source, ramp) {
if (!ramp) return;
for (var i = 0; i < img.data.length; i += 4) {
if (img.data[i + 3] === 0) continue;
var k = img.data[i] + ',' + img.data[i + 1] + ',' + img.data[i + 2];
var ri = source[k];
if (ri == null) continue;
var rgb = ramp[Math.min(ri, ramp.length - 1)];
img.data[i] = rgb.r; img.data[i + 1] = rgb.g; img.data[i + 2] = rgb.b;
}
}
function tintedTileCanvas(idx, tintId, colors, slot) {
var a = RK.charAtlas();
tintId = validTint(tintId);
if (!a || tintId === 'none') return null;
var tint = RK.CHAR_TINTS[tintId];
if (!tint || !tint.ramp) return null;
var key = idx + '|' + tintId;
colors = RK.normalizeCharColors(colors, tintId);
slot = slot || 'base';
var skin = RK.CHAR_SKIN_TONES[colors.skin];
var hair = RK.CHAR_HAIR_COLORS[colors.hair];
var slotTint = RK.CHAR_TINTS[slot === 'base' ? colors.base : (colors[slot] || tintId)];
var needsSlotTint = slotTint && slotTint.ramp && (slot !== 'base' || colors.base !== 'none');
var needsBodyPalette = slot === 'base' && ((skin && colors.skin !== 'natural') || (hair && colors.hair !== 'copper') || needsSlotTint);
if (!a || (!needsBodyPalette && !needsSlotTint)) return null;
var key = idx + '|' + slot + '|' + JSON.stringify(colors);
if (tintedTiles[key]) return tintedTiles[key];
var cv = document.createElement('canvas');
@@ -163,26 +247,24 @@
ctx.drawImage(a.img, src.x, src.y, TILE, TILE, 0, 0, TILE, TILE);
var img = ctx.getImageData(0, 0, TILE, TILE);
var ramp = tint.ramp.map(hexToRgb);
for (var i = 0; i < img.data.length; i += 4) {
var k = img.data[i] + ',' + img.data[i + 1] + ',' + img.data[i + 2];
var ri = TINT_SOURCE[k];
if (ri == null || img.data[i + 3] === 0) continue;
var rgb = ramp[Math.min(ri, ramp.length - 1)];
img.data[i] = rgb.r; img.data[i + 1] = rgb.g; img.data[i + 2] = rgb.b;
if (slot === 'base') {
applyRamp(img, SKIN_SOURCE, skin && colors.skin !== 'natural' ? skin.ramp.map(hexToRgb) : null);
applyRamp(img, HAIR_SOURCE, hair && colors.hair !== 'copper' ? hair.ramp.map(hexToRgb) : null);
}
applyRamp(img, TINT_SOURCE, slotTint && slotTint.ramp ? slotTint.ramp.map(hexToRgb) : null);
ctx.putImageData(img, 0, 0);
tintedTiles[key] = cv;
return cv;
}
RK.charTileTexture = function (idx, tintId) {
RK.charTileTexture = function (idx, tintId, colors, slot) {
tintId = validTint(tintId);
if (tintId === 'none') return RK.charTexture(idx);
if (typeof PIXI === 'undefined') return null;
var key = idx + '|' + tintId;
colors = RK.normalizeCharColors(colors, tintId);
slot = slot || 'base';
var key = idx + '|' + slot + '|' + JSON.stringify(colors);
if (tintedTextures[key]) return tintedTextures[key];
var cv = tintedTileCanvas(idx, tintId);
var cv = tintedTileCanvas(idx, tintId, colors, slot);
if (!cv) return RK.charTexture(idx);
var tx = PIXI.Texture.from(cv);
if (tx.baseTexture) tx.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST;
@@ -190,10 +272,10 @@
return tx;
};
RK.drawCharTileCanvas = function (ctx, idx, tintId, dx, dy, dw, dh) {
RK.drawCharTileCanvas = function (ctx, idx, tintId, dx, dy, dw, dh, colors, slot) {
var a = RK.charAtlas();
if (!a) return false;
var tinted = tintedTileCanvas(idx, tintId);
var tinted = tintedTileCanvas(idx, tintId, colors, slot);
ctx.imageSmoothingEnabled = false;
if (tinted) {
ctx.drawImage(tinted, 0, 0, TILE, TILE, dx, dy, dw, dh);
@@ -206,13 +288,14 @@
RK.charOverlayParts = function (appearance) {
appearance = appearance || {};
var tint = validTint(appearance.tint);
var colors = RK.normalizeCharColors(appearance.colors, appearance.tint);
var tint = colors.base;
var eq = RK.normalizeCharEquipment(appearance.equipment);
var out = [];
EQUIP_SLOTS.forEach(function (slot) {
var item = RK.CHAR_EQUIPMENT[slot][eq[slot]];
if (!item || item.frame == null) return;
out.push({ slot: slot, id: item.id, frame: item.frame, tint: item.tint === false ? 'none' : tint });
out.push({ slot: slot, id: item.id, frame: item.frame, tint: item.tint === false ? 'none' : tint, colorable: item.tint !== false });
});
return out;
};
@@ -234,9 +317,9 @@
ctx.beginPath(); ctx.ellipse(cx, e.y * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2); ctx.stroke();
}
function drawComposite(dx, dy2) {
RK.drawCharTileCanvas(ctx, f.idx, appearance.tint, dx, dy2, dw, dh);
RK.drawCharTileCanvas(ctx, f.idx, appearance.tint, dx, dy2, dw, dh, appearance.colors, 'base');
RK.charOverlayParts(appearance).forEach(function (part) {
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh);
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? appearance.colors : null, part.slot);
});
}
if (f.flip) {
@@ -260,9 +343,9 @@
var ap = RK.charAppearance({ avatar: appearance && appearance.avatar, appearance: appearance });
var ch = RK.CHARS[ap.avatar];
if (!ch) return false;
RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy, size, size);
RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy, size, size, ap.colors, 'base');
RK.charOverlayParts(ap).forEach(function (part) {
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy, size, size);
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy, size, size, part.colorable ? ap.colors : null, part.slot);
});
return true;
};
+2 -2
View File
@@ -265,10 +265,10 @@
var h = this.cell * 1.8;
var body = new PIXI.Container();
body.y = this.cell * 0.5;
var baseTex = window.RK.charTileTexture(ch.frame, appearance.tint);
var baseTex = window.RK.charTileTexture(ch.frame, appearance.tint, appearance.colors, 'base');
if (baseTex) body.addChild(configureCharPart(new PIXI.Sprite(baseTex), h));
window.RK.charOverlayParts(appearance).forEach(function (part) {
var tex = window.RK.charTileTexture(part.frame, part.tint);
var tex = window.RK.charTileTexture(part.frame, part.tint, part.colorable ? appearance.colors : null, part.slot);
if (tex) body.addChild(configureCharPart(new PIXI.Sprite(tex), h));
});
c.addChild(body);
+39 -3
View File
@@ -7,6 +7,8 @@ const CHAR_AVATARS = [
];
const CHAR_TINTS = ['none', 'rose', 'teal', 'moss', 'gold', 'violet', 'ash'];
const CHAR_SKIN_TONES = ['natural', 'fair', 'warm', 'umber', 'olive', 'ash'];
const CHAR_HAIR_COLORS = ['copper', 'brown', 'black', 'blond', 'silver', 'violet'];
const CHAR_EQUIPMENT_SLOTS = ['body', 'head', 'shield', 'weapon'];
const CHAR_EQUIPMENT = {
body: ['none', 'robe', 'jerkin', 'mail', 'sash'],
@@ -30,6 +32,15 @@ const DEFAULT_EQUIPMENT = CHAR_EQUIPMENT_SLOTS.reduce((out, slot) => {
out[slot] = 'none';
return out;
}, {});
const DEFAULT_COLORS = Object.freeze({
base: 'none',
skin: 'natural',
hair: 'copper',
body: 'none',
head: 'none',
shield: 'none',
weapon: 'none'
});
function includes(list, id) {
return list.indexOf(id) !== -1;
@@ -60,6 +71,26 @@ function normalizeEquipment(input = {}) {
return out;
}
function validTint(id, fallback = 'none') {
return includes(CHAR_TINTS, id) ? id : fallback;
}
function normalizeColors(input = {}, legacyTint = 'none') {
const colors = input && typeof input === 'object' ? input : {};
const tintFor = slot => validTint(
Object.prototype.hasOwnProperty.call(colors, slot) ? colors[slot] : legacyTint
);
return {
base: tintFor('base'),
skin: includes(CHAR_SKIN_TONES, colors.skin) ? colors.skin : DEFAULT_COLORS.skin,
hair: includes(CHAR_HAIR_COLORS, colors.hair) ? colors.hair : DEFAULT_COLORS.hair,
body: tintFor('body'),
head: tintFor('head'),
shield: tintFor('shield'),
weapon: tintFor('weapon')
};
}
function normalizeAppearance(input = {}) {
if (input && input.appearance && !input.avatar) input = input.appearance;
if (typeof input === 'string') input = { avatar: input };
@@ -72,11 +103,12 @@ function normalizeAppearance(input = {}) {
return { avatar, tint: 'none', equipment: { ...DEFAULT_EQUIPMENT } };
}
const tint = includes(CHAR_TINTS, input.tint) ? input.tint : 'none';
const colors = normalizeColors(input.colors, input.tint);
return {
avatar,
tint,
equipment: normalizeEquipment(input.equipment)
tint: colors.base,
equipment: normalizeEquipment(input.equipment),
colors
};
}
@@ -86,9 +118,12 @@ module.exports = {
MODEL3D_AVATARS,
CHAR_AVATARS,
CHAR_TINTS,
CHAR_SKIN_TONES,
CHAR_HAIR_COLORS,
CHAR_EQUIPMENT,
CHAR_EQUIPMENT_SLOTS,
DEFAULT_EQUIPMENT,
DEFAULT_COLORS,
avatarIds: () => ALL_AVATARS.slice(),
premiumAvatarIds: () => Object.keys(AVATAR_PACKS),
avatarPack,
@@ -96,5 +131,6 @@ module.exports = {
isCharAvatar,
isPremiumAvatar,
normalizeEquipment,
normalizeColors,
normalizeAppearance
};
+17 -3
View File
@@ -1,15 +1,29 @@
const Appearance = require('../src/multiplayer/appearance');
describe('appearance catalog normalization', () => {
test('normalizes character tint and equipment slots', () => {
test('normalizes character colors and equipment slots', () => {
expect(Appearance.normalizeAppearance({
avatar: 'char-ranger',
tint: 'moss',
equipment: { body: 'mail', head: 'helm', shield: 'bad', weapon: 'bow' }
equipment: { body: 'mail', head: 'helm', shield: 'bad', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'bad', shield: 'rose', weapon: 'ash' }
})).toEqual({
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'helm', shield: 'none', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'none', shield: 'rose', weapon: 'ash' }
});
});
test('uses legacy tint as the default character color ramp', () => {
expect(Appearance.normalizeAppearance({
avatar: 'char-ranger',
tint: 'moss'
})).toEqual({
avatar: 'char-ranger',
tint: 'moss',
equipment: { body: 'mail', head: 'helm', shield: 'none', weapon: 'bow' }
equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' },
colors: { base: 'moss', skin: 'natural', hair: 'copper', body: 'moss', head: 'moss', shield: 'moss', weapon: 'moss' }
});
});
+7 -4
View File
@@ -15,7 +15,8 @@ function makeDb({ user, grants = [] } = {}) {
appearance: {
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' }
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
},
...user
},
@@ -58,13 +59,15 @@ describe('IdentityService', () => {
const snapshot = await svc.saveAppearanceForSocket(makeSocket(), {
avatar: 'char-ranger',
tint: 'bad',
equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' }
equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' },
colors: { base: 'violet', skin: 'bad', hair: 'black', body: 'gold', head: 'bad', shield: 'rose', weapon: 'bad' }
});
expect(snapshot.appearance).toEqual({
avatar: 'char-ranger',
tint: 'none',
equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' }
tint: 'violet',
equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' },
colors: { base: 'violet', skin: 'natural', hair: 'black', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
});
expect(db.state.user.appearance).toEqual(snapshot.appearance);
expect(sessionManager.sessions.get('sockA').appearance).toEqual(snapshot.appearance);
+7 -4
View File
@@ -79,7 +79,8 @@ describe('TavernManager', () => {
const appearance = {
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' }
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
};
await mgr.join(s, { name: 'Alice', avatar: 'green', appearance });
@@ -101,15 +102,17 @@ describe('TavernManager', () => {
appearance: {
avatar: 'char-ranger',
tint: 'bad',
equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' }
equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' },
colors: { base: 'violet', skin: 'bad', hair: 'black', body: 'gold', head: 'bad', shield: 'rose', weapon: 'bad' }
}
});
const occ = mgr.room.getOccupant('a');
expect(occ.avatar).toBe('char-ranger');
expect(occ.appearance).toEqual({
avatar: 'char-ranger',
tint: 'none',
equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' }
tint: 'violet',
equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' },
colors: { base: 'violet', skin: 'natural', hair: 'black', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
});
expect(occ.name).not.toMatch(/[<>]/);
expect(occ.name.length).toBeLessThanOrEqual(16);