mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
The skin/hair/cloth-base/gear palette-ramp recoloring on the top-down Tiles character sprites looked bad, so it's disabled: tintedTileCanvas() now returns null, making every caller (drawCharTileCanvas, charTileTexture, in-game + customizer preview + Pixi) fall through to the native Kenney sprite art. The customizer's top-down editor drops the Cloth/Skin/Hair and per-gear color rows and keeps only equipment PIECE selection (body/head/shield/weapon). The ASCII '@' color picker is unchanged (shown in the ascii/color branch, driven by colors.base via avatarVisuals.tintColorFor). Iso skin tint and premium generated-skins are untouched. Suite 46/317 green.
360 lines
16 KiB
JavaScript
360 lines
16 KiB
JavaScript
// Roguelike top-down characters (free tier). Each character is a SINGLE sprite on Kenney's
|
|
// character sheet (adjacent columns are DIFFERENT characters, not walk frames). We animate
|
|
// entirely procedurally: a vertical bob + squash while moving, horizontal flip when facing left.
|
|
(function (root) {
|
|
'use strict';
|
|
var RK = root.RK = root.RK || {};
|
|
|
|
var COLS = 54, TILE = 16, SHEET = 'assets/kenney/roguelikeChar.png';
|
|
var EQUIP_SLOTS = ['body', 'head', 'shield', 'weapon'];
|
|
|
|
// Kenney's sheet includes recolour-friendly purple/magenta pixels. We remap only those
|
|
// exact source pixels so skin, outlines, wood, and metal keep their authored colours.
|
|
var TINT_SOURCE = {
|
|
'160,132,196': 0, // dark purple
|
|
'181,159,206': 1,
|
|
'199,182,220': 2,
|
|
'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 },
|
|
rose: { id: 'rose', label: 'Rose', color: '#d85c78', ramp: ['#8f3d54', '#b84f6b', '#d96f8c', '#f4aac0'] },
|
|
teal: { id: 'teal', label: 'Teal', color: '#37aaa5', ramp: ['#1f6360', '#287d79', '#37aaa5', '#99dee3'] },
|
|
moss: { id: 'moss', label: 'Moss', color: '#7bad2c', ramp: ['#4f721d', '#639535', '#7bad2c', '#95ce3e'] },
|
|
gold: { id: 'gold', label: 'Gold', color: '#d29922', ramp: ['#8d6640', '#b08052', '#d29922', '#eee3b3'] },
|
|
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: {
|
|
none: { id: 'none', label: 'None', frame: null },
|
|
robe: { id: 'robe', label: 'Robe', frame: 14 },
|
|
jerkin: { id: 'jerkin', label: 'Jerkin', frame: 122 },
|
|
mail: { id: 'mail', label: 'Mail', frame: 230 },
|
|
sash: { id: 'sash', label: 'Sash', frame: 440 }
|
|
},
|
|
head: {
|
|
none: { id: 'none', label: 'None', frame: null },
|
|
hood: { id: 'hood', label: 'Hood', frame: 24 },
|
|
helm: { id: 'helm', label: 'Helm', frame: 454 },
|
|
horns: { id: 'horns', label: 'Horns', frame: 354 },
|
|
cap: { id: 'cap', label: 'Cap', frame: 462 }
|
|
},
|
|
shield: {
|
|
none: { id: 'none', label: 'None', frame: null },
|
|
round: { id: 'round', label: 'Round', frame: 254 },
|
|
kite: { id: 'kite', label: 'Kite', frame: 304 },
|
|
tower: { id: 'tower', label: 'Tower', frame: 202 },
|
|
buckler:{ id: 'buckler', label: 'Buckler', frame: 411 }
|
|
},
|
|
weapon: {
|
|
none: { id: 'none', label: 'None', frame: null },
|
|
staff: { id: 'staff', label: 'Staff', frame: 154 },
|
|
sword: { id: 'sword', label: 'Sword', frame: 369 },
|
|
axe: { id: 'axe', label: 'Axe', frame: 375 },
|
|
bow: { id: 'bow', label: 'Bow', frame: 107 }
|
|
}
|
|
};
|
|
RK.CHAR_EQUIPMENT_SLOTS = EQUIP_SLOTS.slice();
|
|
|
|
// Curated humanoid characters (verified non-empty, by sight), one tile index each.
|
|
RK.CHARS = {
|
|
'char-villager': { id: 'char-villager', label: 'Villager', frame: 270 },
|
|
'char-elder': { id: 'char-elder', label: 'Elder', frame: 271 },
|
|
'char-barbarian': { id: 'char-barbarian', label: 'Barbarian', frame: 324 },
|
|
'char-monk': { id: 'char-monk', label: 'Monk', frame: 379 },
|
|
'char-ranger': { id: 'char-ranger', label: 'Ranger', frame: 432 },
|
|
'char-bard': { id: 'char-bard', label: 'Bard', frame: 486 },
|
|
'char-rogue': { id: 'char-rogue', label: 'Rogue', frame: 540 },
|
|
'char-merchant': { id: 'char-merchant', label: 'Merchant', frame: 541 },
|
|
'char-wizard': { id: 'char-wizard', label: 'Wizard', frame: 595 },
|
|
'char-goblin': { id: 'char-goblin', label: 'Goblin', frame: 162 }
|
|
};
|
|
RK.isChar = function (id) { return !!(id && RK.CHARS[id]); };
|
|
|
|
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] || {};
|
|
return (id && catalog[id]) ? id : 'none';
|
|
}
|
|
|
|
RK.normalizeCharEquipment = function (equipment) {
|
|
equipment = equipment || {};
|
|
var out = {};
|
|
EQUIP_SLOTS.forEach(function (slot) {
|
|
out[slot] = validEquipment(slot, equipment[slot]);
|
|
});
|
|
return out;
|
|
};
|
|
|
|
RK.charAppearance = function (e) {
|
|
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: colors.base,
|
|
equipment: RK.normalizeCharEquipment(ap.equipment),
|
|
colors: colors
|
|
};
|
|
};
|
|
|
|
RK.charRenderKey = function (e) {
|
|
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('|');
|
|
};
|
|
|
|
var atlas = null;
|
|
var tintedTiles = {};
|
|
var tintedTextures = {};
|
|
RK.loadCharAtlas = function (cb) {
|
|
if (!atlas) atlas = new RK.TilesetAtlas(SHEET, { tile: TILE, spacing: 1 });
|
|
atlas.onReady(function () { if (cb) cb(atlas); });
|
|
return atlas;
|
|
};
|
|
RK.charAtlas = function () { return (atlas && atlas.ready) ? atlas : null; };
|
|
|
|
var last = {}; // procedural animation state per entity
|
|
RK.charFrame = function (ch, e, nowMs) {
|
|
var st = last[e.id] || (last[e.id] = { x: e.x, y: e.y, t: 0 });
|
|
if (e.x !== st.x || e.y !== st.y) { st.x = e.x; st.y = e.y; st.t = nowMs; }
|
|
var moving = (nowMs - st.t) < 300;
|
|
return {
|
|
idx: ch.frame,
|
|
bob: moving ? -Math.abs(Math.sin(nowMs / 100)) * 2.5 : Math.sin(nowMs / 480) * 0.6,
|
|
squash: moving ? 1 + Math.sin(nowMs / 100) * 0.07 : 1,
|
|
flip: e.facing === 'left',
|
|
moving: moving
|
|
};
|
|
};
|
|
|
|
RK.charTexture = function (idx) {
|
|
var a = RK.charAtlas();
|
|
if (!a) return null;
|
|
return a.texture(idx % a.cols, Math.floor(idx / a.cols));
|
|
};
|
|
|
|
function hexToRgb(hex) {
|
|
var n = parseInt(String(hex).replace('#', ''), 16);
|
|
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
}
|
|
|
|
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) {
|
|
// Sprite recoloring (skin / hair / cloth-base / gear tinting) is intentionally disabled:
|
|
// characters and equipment render in their native Kenney art. Returning null makes every
|
|
// caller (drawCharTileCanvas, charTileTexture) fall through to the untinted sprite. The
|
|
// ASCII '@' colour is unaffected — it uses colors.base via avatarVisuals.tintColorFor,
|
|
// a separate path. To restore tinting, delete this early return.
|
|
return null;
|
|
var a = RK.charAtlas();
|
|
tintId = validTint(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 slotRamp = slotTint && slotTint.ramp ? slotTint.ramp.map(hexToRgb) : null;
|
|
var needsSlotTint = !!slotRamp && (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');
|
|
cv.width = cv.height = TILE;
|
|
var ctx = cv.getContext('2d');
|
|
var src = a.px(idx % a.cols, Math.floor(idx / a.cols));
|
|
ctx.drawImage(a.img, src.x, src.y, TILE, TILE, 0, 0, TILE, TILE);
|
|
|
|
var img = ctx.getImageData(0, 0, TILE, TILE);
|
|
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, slotRamp);
|
|
ctx.putImageData(img, 0, 0);
|
|
tintedTiles[key] = cv;
|
|
return cv;
|
|
}
|
|
|
|
RK.charTileTexture = function (idx, tintId, colors, slot) {
|
|
tintId = validTint(tintId);
|
|
if (typeof PIXI === 'undefined') return null;
|
|
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, colors, slot);
|
|
if (!cv) return RK.charTexture(idx);
|
|
var tx = PIXI.Texture.from(cv);
|
|
if (tx.baseTexture) tx.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST;
|
|
tintedTextures[key] = tx;
|
|
return tx;
|
|
};
|
|
|
|
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, colors, slot);
|
|
ctx.imageSmoothingEnabled = false;
|
|
if (tinted) {
|
|
ctx.drawImage(tinted, 0, 0, TILE, TILE, dx, dy, dw, dh);
|
|
return true;
|
|
}
|
|
var src = a.px(idx % a.cols, Math.floor(idx / a.cols));
|
|
ctx.drawImage(a.img, src.x, src.y, TILE, TILE, dx, dy, dw, dh);
|
|
return true;
|
|
};
|
|
|
|
RK.charOverlayParts = function (appearance) {
|
|
appearance = appearance || {};
|
|
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;
|
|
var colorable = item.colorable === true;
|
|
out.push({ slot: slot, id: item.id, frame: item.frame, tint: colorable ? tint : 'none', colorable: colorable });
|
|
});
|
|
return out;
|
|
};
|
|
|
|
// Canvas draw (TileRenderer + thumbnails). Returns false (and lazy-loads) if not ready.
|
|
RK.drawCharCanvas = function (ctx, e, cell, nowMs) {
|
|
var a = RK.charAtlas();
|
|
if (!a) { RK.loadCharAtlas(); return false; }
|
|
var appearance = RK.charAppearance(e);
|
|
var ch = RK.CHARS[appearance.avatar];
|
|
if (!ch) return false;
|
|
var f = RK.charFrame(ch, e, nowMs);
|
|
var dw = cell * 1.8, dh = dw * f.squash;
|
|
var cx = e.x * cell + cell / 2;
|
|
var dy = (e.y * cell + cell) - dh + f.bob; // feet at tile bottom, squashed upward
|
|
ctx.imageSmoothingEnabled = false;
|
|
if (e.you) {
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 2;
|
|
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, appearance.colors, 'base');
|
|
RK.charOverlayParts(appearance).forEach(function (part) {
|
|
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? appearance.colors : null, part.slot);
|
|
});
|
|
}
|
|
if (f.flip) {
|
|
ctx.save();
|
|
ctx.translate(cx + dw / 2, dy); ctx.scale(-1, 1);
|
|
drawComposite(0, 0);
|
|
ctx.restore();
|
|
} else {
|
|
drawComposite(cx - dw / 2, dy);
|
|
}
|
|
if (e.label) {
|
|
ctx.fillStyle = '#d7dbe0'; ctx.font = '11px monospace'; ctx.textAlign = 'center';
|
|
ctx.fillText(e.label, cx, dy - 2);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
RK.drawCharCompositeCanvas = function (ctx, appearance, dx, dy, size) {
|
|
var a = RK.charAtlas();
|
|
if (!a) { RK.loadCharAtlas(); return false; }
|
|
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, ap.colors, 'base');
|
|
RK.charOverlayParts(ap).forEach(function (part) {
|
|
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy, size, size, part.colorable ? ap.colors : null, part.slot);
|
|
});
|
|
return true;
|
|
};
|
|
})(window);
|