diff --git a/html/index.html b/html/index.html index 6674d33..5eed130 100644 --- a/html/index.html +++ b/html/index.html @@ -108,7 +108,7 @@ - - \ No newline at end of file + diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js index 2f5094b..f28c714 100644 --- a/html/js/render/assetPacks.js +++ b/html/js/render/assetPacks.js @@ -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 }; diff --git a/html/js/render/charCustomize.js b/html/js/render/charCustomize.js index e999e7e..1995f0c 100644 --- a/html/js/render/charCustomize.js +++ b/html/js/render/charCustomize.js @@ -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); diff --git a/html/js/render/charSprites.js b/html/js/render/charSprites.js index 623f32a..6729276 100644 --- a/html/js/render/charSprites.js +++ b/html/js/render/charSprites.js @@ -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; }; diff --git a/html/js/render/fancyRenderer.js b/html/js/render/fancyRenderer.js index 82f0abe..280f8ee 100644 --- a/html/js/render/fancyRenderer.js +++ b/html/js/render/fancyRenderer.js @@ -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); diff --git a/src/multiplayer/appearance.js b/src/multiplayer/appearance.js index b934552..a0fe904 100644 --- a/src/multiplayer/appearance.js +++ b/src/multiplayer/appearance.js @@ -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 }; diff --git a/test/appearance.test.js b/test/appearance.test.js index 6302bc0..44045c1 100644 --- a/test/appearance.test.js +++ b/test/appearance.test.js @@ -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' } }); }); diff --git a/test/identityService.test.js b/test/identityService.test.js index 791c68d..a681545 100644 --- a/test/identityService.test.js +++ b/test/identityService.test.js @@ -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); diff --git a/test/tavernManager.test.js b/test/tavernManager.test.js index ae90726..7b8480e 100644 --- a/test/tavernManager.test.js +++ b/test/tavernManager.test.js @@ -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);